1212 * <p>Three classes of WC3 BJ calls are transformed:
1313 * <ol>
1414 * <li><b>GetHandleId</b> – replaced 1:1 by {@code __wurst_GetHandleId}, whose Lua
15- * implementation uses a stable table counter instead of the WC3 handle ID
16- * (which can desync in Lua mode) .</li>
15+ * implementation uses a stable table counter for selected opaque runtime handle
16+ * families only. Enum-like handle families keep native semantics in Lua.</li>
1717 * <li><b>Hashtable natives</b> ({@code SaveInteger}, {@code LoadBoolean}, …) and
1818 * <b>context-callback natives</b> ({@code ForForce}, {@code ForGroup}, …) –
1919 * replaced 1:1 by their {@code __wurst_} prefixed equivalents, whose Lua
2020 * implementations are provided by {@link de.peeeq.wurstscript.translation.lua.translation.LuaNatives}.</li>
2121 * <li><b>All other BJ calls with at least one handle-typed parameter</b> – wrapped
22- * by a generated IM function that first checks each handle param for {@code null}
23- * and returns the type-appropriate default (0 / 0.0 / false / "" / nil), then
24- * delegates to the original BJ function. This matches Jass behavior, which
25- * silently returns defaults on null-handle calls instead of crashing.</li>
22+ * by a generated IM function that first checks each required handle param for
23+ * {@code null} and returns the type-appropriate default (0 / 0.0 / false / "" / nil),
24+ * then delegates to the original BJ function. This matches Jass behavior, which
25+ * silently returns defaults on null-handle calls instead of crashing.
26+ * {@code boolexpr} and {@code code} typed params are intentionally skipped: these
27+ * are optional/nullable in Jass (e.g. the filter arg of
28+ * {@code TriggerRegisterPlayerUnitEvent}) and passing {@code nil} is valid.</li>
2629 * </ol>
2730 *
2831 * <p>IS_NATIVE stubs added for category 1 and 2 are recognised by
@@ -69,6 +72,26 @@ public final class LuaNativeLowering {
6972 "EnumDestructablesInRect" , "GetEnumDestructable"
7073 ));
7174
75+ /** True runtime-object handles that should use Lua-side object identity for GetHandleId. */
76+ private static final Set <String > OPAQUE_RUNTIME_HANDLE_TYPES = new HashSet <>(Arrays .asList (
77+ "unit" , "item" , "destructable" , "effect" , "lightning" , "timer" , "trigger" ,
78+ "triggeraction" , "triggercondition" , "boolexpr" , "force" , "group" , "location" ,
79+ "rect" , "region" , "sound" , "dialog" , "button" , "quest" , "questitem" ,
80+ "leaderboard" , "multiboard" , "multiboarditem" , "trackable" , "texttag" ,
81+ "image" , "ubersplat" , "framehandle" , "fogmodifier" , "hashtable"
82+ ));
83+
84+ /**
85+ * When {@code true}, only opaque runtime-handle families (unit, item, timer, …)
86+ * are shimmed via {@code __wurst_GetHandleId}; enum-like handle families
87+ * (eventid, playerevent, …) keep native {@code GetHandleId} semantics.
88+ *
89+ * When {@code false} (safe default), ALL {@code GetHandleId} calls are shimmed
90+ * unconditionally — this matches the pre-selective-shim behaviour and avoids
91+ * any desync risk while the selective logic is being validated.
92+ */
93+ public static final boolean ENABLE_SELECTIVE_GET_HANDLE_ID_SHIMMING = true ;
94+
7295 private LuaNativeLowering () {}
7396
7497 /**
@@ -100,6 +123,7 @@ public static void transform(ImProg prog) {
100123 // Maps original BJ function → replacement (IS_NATIVE stub or nil-safety wrapper).
101124 // Populated lazily during the traversal.
102125 Map <ImFunction , ImFunction > replacements = new LinkedHashMap <>();
126+ Map <String , ImFunction > specialNativeStubs = new LinkedHashMap <>();
103127 // BJ functions that don't need a replacement (not GetHandleId, not hashtable/callback,
104128 // no handle params). Cached to avoid rechecking the same function at every call site.
105129 Set <ImFunction > noReplacement = new HashSet <>();
@@ -114,7 +138,37 @@ public static void transform(ImProg prog) {
114138 public void visit (ImFunctionCall call ) {
115139 super .visit (call );
116140 ImFunction f = call .getFunc ();
141+ if (ENABLE_SELECTIVE_GET_HANDLE_ID_SHIMMING && isCompatGetHandleIdFunction (f )) {
142+ if (shouldRewriteGetHandleId (call )) {
143+ ImFunction replacement = specialNativeStubs .computeIfAbsent ("__wurst_GetHandleId" ,
144+ name -> createNativeStub (name , f ));
145+ if (!deferredAdditions .contains (replacement )) {
146+ deferredAdditions .add (replacement );
147+ }
148+ call .replaceBy (JassIm .ImFunctionCall (
149+ call .attrTrace (), replacement ,
150+ JassIm .ImTypeArguments (),
151+ call .getArguments ().copy (),
152+ false , CallType .NORMAL ));
153+ }
154+ return ;
155+ }
117156 if (!f .isBj ()) return ;
157+ if ("GetHandleId" .equals (f .getName ())) {
158+ if (ENABLE_SELECTIVE_GET_HANDLE_ID_SHIMMING && shouldRewriteGetHandleId (call )) {
159+ ImFunction replacement = specialNativeStubs .computeIfAbsent ("__wurst_GetHandleId" ,
160+ name -> createNativeStub (name , f ));
161+ if (!deferredAdditions .contains (replacement )) {
162+ deferredAdditions .add (replacement );
163+ }
164+ call .replaceBy (JassIm .ImFunctionCall (
165+ call .attrTrace (), replacement ,
166+ JassIm .ImTypeArguments (),
167+ call .getArguments ().copy (),
168+ false , CallType .NORMAL ));
169+ }
170+ return ;
171+ }
118172 if (noReplacement .contains (f )) return ;
119173
120174 if (!replacements .containsKey (f )) {
@@ -139,9 +193,7 @@ public void visit(ImFunctionCall call) {
139193
140194 private ImFunction computeReplacement (ImFunction bj ) {
141195 String name = bj .getName ();
142- if ("GetHandleId" .equals (name )) {
143- return createNativeStub ("__wurst_GetHandleId" , bj );
144- } else if (HASHTABLE_NATIVE_NAMES .contains (name )) {
196+ if (HASHTABLE_NATIVE_NAMES .contains (name )) {
145197 return createNativeStub ("__wurst_" + name , bj );
146198 } else if (CONTEXT_CALLBACK_NATIVE_NAMES .contains (name )) {
147199 return createNativeStub ("__wurst_" + name , bj );
@@ -195,10 +247,12 @@ private static ImFunction createNilSafeWrapper(ImFunction bjNative) {
195247
196248 ImStmts body = JassIm .ImStmts ();
197249
198- // Null-check each handle param: if param == null then return <default> end
250+ // Null-check each required handle param: if param == null then return <default> end
251+ // boolexpr and code params are intentionally skipped — they are optional/nullable
252+ // in Jass (e.g. the filter arg of TriggerRegisterPlayerUnitEvent).
199253 ImExpr returnDefault = defaultValueExpr (bjNative .getReturnType ());
200254 for (ImVar param : paramVars ) {
201- if (isHandleType (param .getType ())) {
255+ if (isHandleType (param .getType ()) && ! isNullableHandleType ( param . getType ()) ) {
202256 ImExpr condition = JassIm .ImOperatorCall (WurstOperator .EQ , JassIm .ImExprs (
203257 JassIm .ImVarAccess (param ),
204258 JassIm .ImNull (param .getType ().copy ())
@@ -244,14 +298,56 @@ private static boolean hasHandleParam(ImFunction f) {
244298 }
245299
246300 /** Returns true for WC3 handle types (ImSimpleType that is not int/real/boolean/string). */
247- static boolean isHandleType (ImType type ) {
301+ public static boolean isHandleType (ImType type ) {
248302 if (!(type instanceof ImSimpleType )) {
249303 return false ;
250304 }
251305 String n = ((ImSimpleType ) type ).getTypename ();
252306 return !n .equals ("integer" ) && !n .equals ("real" ) && !n .equals ("boolean" ) && !n .equals ("string" );
253307 }
254308
309+ /**
310+ * Returns true for handle types that are valid to pass as {@code null} in Jass without
311+ * triggering a null-handle crash. These params are skipped in nil-safety wrappers.
312+ *
313+ * <p>{@code boolexpr} and {@code code} are the canonical optional types: every WC3
314+ * API that takes them (filter, condition, action callbacks) accepts {@code null} to
315+ * mean "no callback".
316+ */
317+ static boolean isNullableHandleType (ImType type ) {
318+ if (!(type instanceof ImSimpleType )) {
319+ return false ;
320+ }
321+ String n = ((ImSimpleType ) type ).getTypename ();
322+ return n .equals ("boolexpr" ) || n .equals ("code" );
323+ }
324+
325+ private static boolean shouldRewriteGetHandleId (ImFunctionCall call ) {
326+ if (call .getArguments ().size () != 1 ) {
327+ return true ;
328+ }
329+ return usesLuaObjectIdentityHandleId (call .getArguments ().get (0 ).attrTyp ());
330+ }
331+
332+ public static boolean usesLuaObjectIdentityHandleId (ImType type ) {
333+ if (!(type instanceof ImSimpleType )) {
334+ return false ;
335+ }
336+ String typeName = ((ImSimpleType ) type ).getTypename ();
337+ return OPAQUE_RUNTIME_HANDLE_TYPES .contains (typeName );
338+ }
339+
340+ private static boolean isCompatGetHandleIdFunction (ImFunction f ) {
341+ if (f .getParameters ().size () != 1
342+ || !f .getName ().endsWith ("_getHandleId" )
343+ || f .getName ().endsWith ("_getTCHandleId" )) {
344+ return false ;
345+ }
346+ // Restrict to WC3 simple handle types (ImSimpleType). User-defined Wurst classes
347+ // use ImClassType and must not have their call sites replaced.
348+ return isHandleType (f .getParameters ().get (0 ).getType ());
349+ }
350+
255351 /** Returns an IM expression representing the safe default for the given return type. */
256352 private static ImExpr defaultValueExpr (ImType returnType ) {
257353 if (returnType instanceof ImSimpleType ) {
0 commit comments