Skip to content

Fix SCRIPT_FUNCTION field offsets after executeFunction for 1.16.244 - #56

Open
kiffpuppygames wants to merge 1 commit into
libxse:mainfrom
kiffpuppygames:fix/script-function-layout
Open

kiffpuppygames wants to merge 1 commit into
libxse:mainfrom
kiffpuppygames:fix/script-function-layout

Conversation

@kiffpuppygames

Copy link
Copy Markdown

SCRIPT_FUNCTION (include/RE/S/Script.h) describes one console command or one script function: its name, help text and parameter list, and the functions the game calls to run it, to compile it from text and to evaluate it as a condition. The game keeps two arrays of these entries: the console commands (GetConsoleCommands, id 896666; 438 are filled in 1.16.244) and the script commands (GetScriptCommands, id 896669; 947 entries, many of which are also the condition functions used on quests, dialogue and perks).

In Starfield 1.16.244 an entry is still 0x58 bytes, but every field after executeFunction sits 8 bytes later than Script.h declares. This PR moves those fields. The size check (static_assert(sizeof(SCRIPT_FUNCTION) == 0x58)) still holds.

Field Declared now Actual in 1.16.244
executeFunction + 0x30 + 0x30 (unchanged)
unknown byte, then 7 bytes of padding (none) + 0x38
compileFunction + 0x38 + 0x40
conditionFunction + 0x40 + 0x48
editorFilter + 0x48 + 0x50
invalidatesCellList + 0x49 + 0x51
padding unk4A to the end + 0x52 to + 0x57

What is in the moved fields

    • 0x38 is a single byte that the game writes together with executeFunction. It is 0 in every entry when the tables are built, and I don't know what it means. + 0x39 to + 0x3F are padding.
  • compileFunction (+ 0x40) is ScriptCompiler::StandardCompile (id 65846) in 944 of the 947 script-command entries (the other three are ShowMessage, EssentialDeathReload and ShowWarning) and in all 438 console-command entries.
  • conditionFunction (+ 0x48) holds the condition function of a script command, for example id 65625 for IsInSpaceship (opcode 0x1338).
  • The two flag bytes are at + 0x50 and + 0x51. Their names carry over by position only: + 0x50 is set on GetIsID, GetIsRace and similar, + 0x51 on Activate, MoveToMarker, ForceFlee and ExitGame, and I found no game code that reads either byte.

Why this has gone unnoticed

Everything up to and including executeFunction is correct: the name, help text, parameter list and execute function. Those are the fields a plugin rewrites when it adds a console command by taking over an existing entry, and it leaves the real compile function at + 0x40 alone, so the game still compiles the typed command. Console commands added this way work.

The moved fields are rarely touched, and touching them mostly has no visible effect:

  • Writing editorFilter and invalidatesCellList, as several plugins do when they take over a console command, writes the low two bytes of the real condition pointer at + 0x48. The condition readers I found (TESConditionItem::IsTrue, and ids 104364 and 104365) read only the script-command array, so for a console-command entry nothing reads that pointer.
  • Reading compileFunction returns the byte at + 0x38, which is always 0. Nothing crashes; the code just gets a wrong answer.

The layout looks plausible. Fallout 4's entry is 0x50 bytes with this same field order (CommonLibF4's include/RE/Bethesda/Script.h: compile at + 0x38, condition at + 0x40, flags at + 0x48). Starfield's entries are 0x58 bytes apart, and Script.h keeps Fallout 4's order and adds the 8 extra bytes as unknown fields at the end. In 1.16.244 they are right after executeFunction. The size check passes either way, since it checks only the total.

What goes wrong with the current layout

  • Calling conditionFunction through the struct calls the entry's compile function (+ 0x40) with condition arguments.
  • Setting conditionFunction to add a condition replaces the entry's compile function, and the game keeps calling the old condition function at + 0x48.
  • Setting compileFunction writes over the unknown byte and its padding, and the game's compile function stays unchanged.
  • Writing the flag bytes on a script command that has a condition function overwrites the low two bytes of that function's pointer.

Evidence

  • TESConditionItem::IsTrue (id 71429, IDs.h:2445), the game's condition evaluator, loads the condition function from GetScriptCommands()[index] + 0x48 (at + 0xb18) and calls it with (ConditionCheckParams&, param1, param2, float&).
  • The per-line script compiler (id 65834) calls [entry + 0x40] with (numParams, params, line, script) at + 0xb58; the expression compiler (id 65837) does the same.
  • The function that copies each entry into the two arrays (id 46078) copies exactly these offsets and widths (+ 0x10d to + 0x133), and the static builders id 7980 (console commands) and id 7982 (script commands) fill them.

I have checked only 1.16.244, so I can't say whether the layout was already like this at launch or changed in a later update.

Other code with the same layout

SFSE 0.2.21's GameScript.h:106-117 declares the same layout, and its Hooks_Script.cpp:95-96 writes bEditorFilter and bInvalidatesCellList into BetaComment's entry. In 1.16.244 those are the low two bytes of that console command's condition pointer, which is null, so the write has no effect.

Code built against the current layout

CommonLibSF is a static library, so already-built plugins are not affected; a plugin picks this change up only when it is rebuilt. One behaviour changes then: a test such as compileFunction == nullptr reads the always-zero byte today, so it is true for every entry, and it is false for every filled entry with this PR. At least one public plugin picks a console-command slot with that test and would need a different one after rebuilding.

SCRIPT_FUNCTION is one entry of the game's console-command and
script-command arrays. In Starfield 1.16.244 an entry is still 0x58
bytes, but + 0x38 is a single byte written with executeFunction (then 7
bytes of padding), compileFunction is at + 0x40, conditionFunction at
+ 0x48 and the two flag bytes at + 0x50 and + 0x51. The declared layout
keeps Fallout 4's field order with the 8 extra bytes at the end, so the
size check passed.

TESConditionItem::IsTrue (71429) calls the condition function from
+ 0x48, the script compiler (65834) calls the compile function from
+ 0x40, and the function that copies the entries into the arrays (46078)
copies exactly these offsets. The flag names carry over by position only.

Code that tests compileFunction == nullptr reads the always-zero byte at
+ 0x38 today and the real compile pointer, set in every filled entry,
after this.
@kiffpuppygames
kiffpuppygames marked this pull request as ready for review November 2, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant