Decomp updates

This commit is contained in:
MaddoScientisto 2026-03-30 00:19:01 +02:00
commit c4fa8a6b05
62 changed files with 9413 additions and 20 deletions

19
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,19 @@
---
description: 'Top-level Copilot instructions for Crusader_Decomp'
applyTo: '**'
---
# Crusader_Decomp Copilot Instructions
## Safety Guardrails
- Never create a git commit on your own.
- Never run a command that may delete files outside a temporary folder unless you first ask the user with `vscode_askQuestions` and get explicit confirmation.
- Treat repository files, generated outputs, and analysis artifacts as user-owned unless the user explicitly asks for deletion.
## Workflow Notes
- Prefer small, focused changes.
- Validate reverse-engineering and tooling changes with the narrowest relevant check.
- Keep read-only analysis separate from any explicit writable workflow.
- If the shell becomes stuck on multiline input or otherwise unhealthy, you could try to press esc in the shell to see if it gets unstuck, otherwise immediately use `vscode_askQuestions` to ask the user to fix the terminal state, then continue the task once the user confirms it is fixed.

View file

@ -4,6 +4,12 @@ applyTo: "**"
# Crusader Ghidra Workflow # Crusader Ghidra Workflow
## Safety Guardrails
- Never create a git commit on your own.
- Never run a command that may delete files outside a temporary folder unless you first ask the user with `vscode_askQuestions` and get explicit confirmation.
- If a request could remove or overwrite repository files, pause and confirm before proceeding.
- Active target is the NE Ghidra program `CRUSADER.EXE` unless explicitly stated otherwise. - Active target is the NE Ghidra program `CRUSADER.EXE` unless explicitly stated otherwise.
- Use Ghidra MCP tools for analysis, decompilation, renaming, comments, and xref work. - Use Ghidra MCP tools for analysis, decompilation, renaming, comments, and xref work.
- Treat the verified `CRUSADER-RAW.EXE` work already captured in `docs/` and notes as a cross-reference evidence base for the live `CRUSADER.EXE` session, not as the default active program. - Treat the verified `CRUSADER-RAW.EXE` work already captured in `docs/` and notes as a cross-reference evidence base for the live `CRUSADER.EXE` session, not as the default active program.

1790
4 Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<FILE_INFO>
<BASIC_INFO>
<STATE NAME="CONTENT_TYPE" TYPE="string" VALUE="Program" />
<STATE NAME="PARENT" TYPE="string" VALUE="/psx/remorse" />
<STATE NAME="FILE_ID" TYPE="string" VALUE="c0a86451c52a19721794868600" />
<STATE NAME="FILE_TYPE" TYPE="int" VALUE="0" />
<STATE NAME="READ_ONLY" TYPE="boolean" VALUE="false" />
<STATE NAME="NAME" TYPE="string" VALUE="SLUS_002.68" />
</BASIC_INFO>
</FILE_INFO>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<FILE_INFO>
<BASIC_INFO>
<STATE NAME="CONTENT_TYPE" TYPE="string" VALUE="Program" />
<STATE NAME="PARENT" TYPE="string" VALUE="/psx/prealpha" />
<STATE NAME="FILE_ID" TYPE="string" VALUE="c0a86451da1a44983408575900" />
<STATE NAME="FILE_TYPE" TYPE="int" VALUE="0" />
<STATE NAME="READ_ONLY" TYPE="boolean" VALUE="false" />
<STATE NAME="NAME" TYPE="string" VALUE="SLUS_002.68" />
</BASIC_INFO>
</FILE_INFO>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -32,8 +32,13 @@ VERSION=1
0000000d:U8.EXE:c0a86451c284202637978076200 0000000d:U8.EXE:c0a86451c284202637978076200
/orig_cd /orig_cd
00000014:CRUSADER.EXE:c0a86451c28b202638339220200 00000014:CRUSADER.EXE:c0a86451c28b202638339220200
/psx
/psx/prealpha
0000001c:SLUS_002.68:c0a86451da1a44983408575900
/psx/remorse
0000001b:SLUS_002.68:c0a86451c52a19721794868600
/regret /regret
00000009:ASYLUM.DLL:c0a86451c280202637798314100 00000009:ASYLUM.DLL:c0a86451c280202637798314100
0000000a:REGRET.EXE:c0a86451c281202637836837200 0000000a:REGRET.EXE:c0a86451c281202637836837200
NEXT-ID:1b NEXT-ID:1d
MD5:d41d8cd98f00b204e9800998ecf8427e MD5:d41d8cd98f00b204e9800998ecf8427e

View file

@ -3,15 +3,524 @@
<PROJECT_DATA_XML_NAME NAME="DISPLAY_DATA"> <PROJECT_DATA_XML_NAME NAME="DISPLAY_DATA">
<SAVE_STATE> <SAVE_STATE>
<ARRAY NAME="EXPANDED_PATHS" TYPE="string"> <ARRAY NAME="EXPANDED_PATHS" TYPE="string">
<A VALUE="Crusader:regret:" /> <A VALUE="Crusader:psx:" />
<A VALUE="Crusader:ja:" /> <A VALUE="Crusader:psx:prealpha:" />
<A VALUE="Crusader:psx:remorse:" />
<A VALUE="Crusader:" /> <A VALUE="Crusader:" />
</ARRAY> </ARRAY>
<STATE NAME="SHOW_TABLE" TYPE="boolean" VALUE="false" /> <STATE NAME="SHOW_TABLE" TYPE="boolean" VALUE="false" />
</SAVE_STATE> </SAVE_STATE>
</PROJECT_DATA_XML_NAME> </PROJECT_DATA_XML_NAME>
<TOOL_MANAGER ACTIVE_WORKSPACE="Workspace"> <TOOL_MANAGER ACTIVE_WORKSPACE="Workspace">
<WORKSPACE NAME="Workspace" ACTIVE="true" /> <WORKSPACE NAME="Workspace" ACTIVE="true">
<RUNNING_TOOL TOOL_NAME="CodeBrowser">
<ROOT_NODE X_POS="14" Y_POS="70" WIDTH="1880" HEIGHT="1110" EX_STATE="0">
<SPLIT_NODE WIDTH="100" HEIGHT="100" DIVIDER_LOCATION="0" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="1866" HEIGHT="1014" DIVIDER_LOCATION="774" ORIENTATION="HORIZONTAL">
<SPLIT_NODE WIDTH="100" HEIGHT="100" DIVIDER_LOCATION="0" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="1866" HEIGHT="1014" DIVIDER_LOCATION="880" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="1866" HEIGHT="889" DIVIDER_LOCATION="863" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="100" HEIGHT="100" DIVIDER_LOCATION="0" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="1621" HEIGHT="816" DIVIDER_LOCATION="148" ORIENTATION="VERTICAL">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Entropy" OWNER="EntropyPlugin" TITLE="Entropy" ACTIVE="false" GROUP="Header" INSTANCE_ID="3207819926581772885" />
<COMPONENT_INFO NAME="Overview" OWNER="OverviewPlugin" TITLE="Overview" ACTIVE="false" GROUP="Header" INSTANCE_ID="3207819926581772883" />
</COMPONENT_NODE>
<SPLIT_NODE WIDTH="1866" HEIGHT="764" DIVIDER_LOCATION="174" ORIENTATION="HORIZONTAL">
<SPLIT_NODE WIDTH="324" HEIGHT="764" DIVIDER_LOCATION="640" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="324" HEIGHT="486" DIVIDER_LOCATION="502" ORIENTATION="VERTICAL">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Program Tree" OWNER="ProgramTreePlugin" TITLE="Program Trees" ACTIVE="true" GROUP="Default" INSTANCE_ID="3722070732148365059" />
</COMPONENT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Symbol Tree" OWNER="SymbolTreePlugin" TITLE="Symbol Tree" ACTIVE="true" GROUP="Default" INSTANCE_ID="3722070719626270494" />
</COMPONENT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="DataTypes Provider" OWNER="DataTypeManagerPlugin" TITLE="Data Type Manager" ACTIVE="true" GROUP="Default" INSTANCE_ID="3722070732731373328" />
</COMPONENT_NODE>
</SPLIT_NODE>
<SPLIT_NODE WIDTH="1538" HEIGHT="764" DIVIDER_LOCATION="785" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="1386" HEIGHT="638" DIVIDER_LOCATION="705" ORIENTATION="VERTICAL">
<SPLIT_NODE WIDTH="1538" HEIGHT="764" DIVIDER_LOCATION="490" ORIENTATION="HORIZONTAL">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Listing" OWNER="CodeBrowserPlugin" TITLE="Listing: SLUS_002.68" ACTIVE="true" GROUP="Core" INSTANCE_ID="3722070732148365069" />
</COMPONENT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Decompiler" OWNER="DecompilePlugin" TITLE="Decompile: main" ACTIVE="true" GROUP="Default" INSTANCE_ID="3722070732148365060" />
<COMPONENT_INFO NAME="Bytes" OWNER="ByteViewerPlugin" TITLE="Bytes: SLUS_002.68" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365062" />
<COMPONENT_INFO NAME="Data Window" OWNER="DataWindowPlugin" TITLE="Defined Data" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373333" />
<COMPONENT_INFO NAME="Defined Strings" OWNER="ViewStringsPlugin" TITLE="Defined Strings" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373337" />
<COMPONENT_INFO NAME="Equates Table" OWNER="EquateTablePlugin" TITLE="Equates Table" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365066" />
<COMPONENT_INFO NAME="External Programs" OWNER="ReferencesPlugin" TITLE="External Programs" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365070" />
<COMPONENT_INFO NAME="Functions Window" OWNER="FunctionWindowPlugin" TITLE="Functions" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365073" />
<COMPONENT_INFO NAME="Relocation Table" OWNER="RelocationTablePlugin" TITLE="Relocation Table" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373336" />
</COMPONENT_NODE>
</SPLIT_NODE>
<SPLIT_NODE WIDTH="1386" HEIGHT="189" DIVIDER_LOCATION="495" ORIENTATION="HORIZONTAL">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Data Type Preview" OWNER="DataTypePreviewPlugin" TITLE="Data Type Preview" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373326" />
</COMPONENT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Virtual Disassembler - Current Instruction" OWNER="DisassembledViewPlugin" TITLE="Disassembled View" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365065" />
</COMPONENT_NODE>
</SPLIT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Console" OWNER="ConsolePlugin" TITLE="Console" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365061" />
<COMPONENT_INFO NAME="Bookmarks" OWNER="BookmarkPlugin" TITLE="Bookmarks" ACTIVE="false" GROUP="Core.Bookmarks" INSTANCE_ID="3722070732148365058" />
</COMPONENT_NODE>
</SPLIT_NODE>
</SPLIT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Function Call Trees" OWNER="CallTreePlugin" TITLE="Function Call Trees" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070719626270495" />
</COMPONENT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Domain Events" OWNER="DomainEventDisplayPlugin" TITLE="Domain Object Event Display" ACTIVE="true" GROUP="Default" INSTANCE_ID="3722070732148365067" />
</COMPONENT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Plugin Event Display" OWNER="EventDisplayPlugin" TITLE="Plugin Event Display" ACTIVE="true" GROUP="Default" INSTANCE_ID="3722070732148365064" />
</COMPONENT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Database Viewer" OWNER="DbViewerPlugin" TITLE="Database Viewer" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070734440552208" />
</COMPONENT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Decompiler" OWNER="DecompilePlugin" TITLE="[Decompile: Weapon_GetDisplayFrameForShape]" ACTIVE="false" GROUP="disconnected" INSTANCE_ID="3721996248960424124" />
</COMPONENT_NODE>
</SPLIT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Diff Location Details" OWNER="ProgramDiffPlugin" TITLE="Diff Details" ACTIVE="false" GROUP="Default" INSTANCE_ID="3721161170088475892" />
</COMPONENT_NODE>
</SPLIT_NODE>
<WINDOW_NODE X_POS="426" Y_POS="178" WIDTH="1033" HEIGHT="689">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Script Manager" OWNER="GhidraScriptMgrPlugin" TITLE="Script Manager" ACTIVE="false" GROUP="Script Group" INSTANCE_ID="3722070732148365056" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="423" Y_POS="144" WIDTH="927" HEIGHT="695">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Memory Map" OWNER="MemoryMapPlugin" TITLE="Memory Map" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070719626270491" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="383" Y_POS="7" WIDTH="1020" HEIGHT="1038">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Function Graph" OWNER="FunctionGraphPlugin" TITLE="Function Graph" ACTIVE="false" GROUP="Function Graph" INSTANCE_ID="3722070732731373338" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="550" Y_POS="206" WIDTH="655" HEIGHT="509">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Register Manager" OWNER="RegisterPlugin" TITLE="Register Manager" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365072" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="287" Y_POS="186" WIDTH="1424" HEIGHT="666">
<SPLIT_NODE WIDTH="1408" HEIGHT="559" DIVIDER_LOCATION="573" ORIENTATION="HORIZONTAL">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Symbol Table" OWNER="SymbolTablePlugin" TITLE="Symbol Table" ACTIVE="false" GROUP="symbolTable" INSTANCE_ID="3722070732731373334" />
</COMPONENT_NODE>
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Symbol References" OWNER="SymbolTablePlugin" TITLE="Symbol References" ACTIVE="false" GROUP="symbolTable" INSTANCE_ID="3722070732731373335" />
</COMPONENT_NODE>
</SPLIT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="-1" Y_POS="-1" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Checksum Generator" OWNER="ComputeChecksumsPlugin" TITLE="Checksum Generator" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365068" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="-1" Y_POS="-1" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Function Tags" OWNER="FunctionTagPlugin" TITLE="Function Tags" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373329" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="-1" Y_POS="-1" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Comment Window" OWNER="CommentWindowPlugin" TITLE="Comments" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373332" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="-1" Y_POS="-1" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Python" OWNER="InterpreterPanelPlugin" TITLE="Python" ACTIVE="false" GROUP="Default" INSTANCE_ID="3207819978370941531" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="0" Y_POS="0" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Jython" OWNER="Jython" TITLE="Jython" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373330" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="0" Y_POS="0" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Bundle Manager" OWNER="GhidraScriptMgrPlugin" TITLE="Bundle Manager" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365057" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="0" Y_POS="0" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="PyGhidra" OWNER="PyGhidra" TITLE="PyGhidra" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732731373331" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="0" Y_POS="0" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Source Files and Transforms" OWNER="SourceFilesTablePlugin" TITLE="Source Files and Transforms" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070732148365071" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="0" Y_POS="0" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Function Call Graph" OWNER="FunctionCallGraphPlugin" TITLE="Function Call Graph" ACTIVE="false" GROUP="Function Call Graph" INSTANCE_ID="3722070732731373327" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="588" Y_POS="50" WIDTH="1018" HEIGHT="1087">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Graph" OWNER="DefaultGraphDisplay" TITLE="AST Data Flow Graph For entity_state_tick_dispatch" ACTIVE="false" GROUP="ProgramGraph" INSTANCE_ID="3720233517670421199" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="0" Y_POS="0" WIDTH="0" HEIGHT="0">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Window Locations" OWNER="WindowLocationPlugin" TITLE="Window Locations" ACTIVE="false" GROUP="Default" INSTANCE_ID="3722070719626270492" />
</COMPONENT_NODE>
</WINDOW_NODE>
<WINDOW_NODE X_POS="890" Y_POS="456" WIDTH="729" HEIGHT="566">
<COMPONENT_NODE TOP_INFO="0">
<COMPONENT_INFO NAME="Location References Provider" OWNER="LocationReferencesPlugin" TITLE="References to g_jassica16Offset - 7 locations" ACTIVE="false" GROUP="Default" INSTANCE_ID="3721161260863700211" />
</COMPONENT_NODE>
</WINDOW_NODE>
</ROOT_NODE>
<DATA_STATE>
<PLUGIN NAME="NavigationHistoryPlugin">
<XML NAME="HISTORY_LIST_0">
<SAVE_STATE>
<STATE NAME="CURRENT_LOC_INDEX" TYPE="int" VALUE="4" />
<STATE NAME="LOCATION_COUNT" TYPE="int" VALUE="5" />
<STATE NAME="MEMENTO_CLASS0" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="MEMENTO_CLASS1" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="MEMENTO_CLASS2" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="MEMENTO_CLASS3" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="MEMENTO_CLASS4" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<XML NAME="MEMENTO_DATA0">
<SAVE_STATE>
<XML NAME="MEMENTO0">
<SAVE_STATE>
<STATE NAME="CURSOR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.codebrowser.CodeViewerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365069" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="1f800000" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="1f800000" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.MemoryBlockStartFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<ARRAY NAME="_COMMENT" TYPE="string">
<A VALUE="//" />
<A VALUE="// CACHE" />
<A VALUE="// ram:1f800000-ram:1f8003ff" />
<A VALUE="//" />
</ARRAY>
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<STATE NAME="_TYPE" TYPE="int" VALUE="-1" />
</SAVE_STATE>
</XML>
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="NUM_MEMENTOS" TYPE="int" VALUE="1" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.LabelFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<ARRAY NAME="_SYMBOL_PATH" TYPE="string">
<A VALUE="main" />
</ARRAY>
</SAVE_STATE>
</XML>
<XML NAME="MEMENTO_DATA1">
<SAVE_STATE>
<XML NAME="MEMENTO0">
<SAVE_STATE>
<STATE NAME="CURSOR_OFFSET" TYPE="int" VALUE="367" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.codebrowser.CodeViewerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365069" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.LabelFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<ARRAY NAME="_SYMBOL_PATH" TYPE="string">
<A VALUE="main" />
</ARRAY>
</SAVE_STATE>
</XML>
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="NUM_MEMENTOS" TYPE="int" VALUE="1" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="1f800000" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="1f800000" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.MemoryBlockStartFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<ARRAY NAME="_COMMENT" TYPE="string">
<A VALUE="//" />
<A VALUE="// CACHE" />
<A VALUE="// ram:1f800000-ram:1f8003ff" />
<A VALUE="//" />
</ARRAY>
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<STATE NAME="_TYPE" TYPE="int" VALUE="-1" />
</SAVE_STATE>
</XML>
<XML NAME="MEMENTO_DATA2">
<SAVE_STATE>
<XML NAME="MEMENTO0">
<SAVE_STATE>
<STATE NAME="INDEX" TYPE="int" VALUE="0" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.decompile.DecompilerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365060" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="X_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="Y_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.LabelFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<ARRAY NAME="_SYMBOL_PATH" TYPE="string">
<A VALUE="main" />
</ARRAY>
</SAVE_STATE>
</XML>
<XML NAME="MEMENTO1">
<SAVE_STATE>
<STATE NAME="CURSOR_OFFSET" TYPE="int" VALUE="367" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.codebrowser.CodeViewerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365069" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.LabelFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<ARRAY NAME="_SYMBOL_PATH" TYPE="string">
<A VALUE="main" />
</ARRAY>
</SAVE_STATE>
</XML>
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="NUM_MEMENTOS" TYPE="int" VALUE="2" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.LabelFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<ARRAY NAME="_SYMBOL_PATH" TYPE="string">
<A VALUE="main" />
</ARRAY>
</SAVE_STATE>
</XML>
<XML NAME="MEMENTO_DATA3">
<SAVE_STATE>
<XML NAME="MEMENTO0">
<SAVE_STATE>
<STATE NAME="INDEX" TYPE="int" VALUE="0" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.decompile.DecompilerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365060" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="X_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="Y_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.BytesFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
</SAVE_STATE>
</XML>
<XML NAME="MEMENTO1">
<SAVE_STATE>
<STATE NAME="CURSOR_OFFSET" TYPE="int" VALUE="-1" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.codebrowser.CodeViewerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365069" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.BytesFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
</SAVE_STATE>
</XML>
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="NUM_MEMENTOS" TYPE="int" VALUE="2" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.BytesFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
</SAVE_STATE>
</XML>
<XML NAME="MEMENTO_DATA4">
<SAVE_STATE>
<XML NAME="MEMENTO0">
<SAVE_STATE>
<STATE NAME="INDEX" TYPE="int" VALUE="0" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.decompile.DecompilerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365060" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="X_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="Y_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002dc4c" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.BytesFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
</SAVE_STATE>
</XML>
<XML NAME="MEMENTO1">
<SAVE_STATE>
<STATE NAME="CURSOR_OFFSET" TYPE="int" VALUE="367" />
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.codebrowser.CodeViewerLocationMemento" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365069" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.FunctionReturnTypeFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_FUNC_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_RETURN_TYPE" TYPE="string" VALUE="undefined" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<STATE NAME="_SIGNATURE" TYPE="string" VALUE="undefined main()" />
</SAVE_STATE>
</XML>
<STATE NAME="MEMENTO_CLASS" TYPE="string" VALUE="ghidra.app.plugin.core.gotoquery.DefaultNavigatableLocationMemento" />
<STATE NAME="NUM_MEMENTOS" TYPE="int" VALUE="2" />
<STATE NAME="PROGRAM_ID" TYPE="long" VALUE="3722068822169278211" />
<STATE NAME="PROGRAM_PATH_" TYPE="string" VALUE="Crusader:/psx/prealpha/SLUS_002.68" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.FunctionReturnTypeFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_FUNC_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_RETURN_TYPE" TYPE="string" VALUE="undefined" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<STATE NAME="_SIGNATURE" TYPE="string" VALUE="undefined main()" />
</SAVE_STATE>
</XML>
<STATE NAME="NAV_ID" TYPE="long" VALUE="-1" />
</SAVE_STATE>
</XML>
<STATE NAME="LIST_COUNT" TYPE="int" VALUE="1" />
</PLUGIN>
<PLUGIN NAME="ProgramTreePlugin">
<STATE NAME="Current Viewname" TYPE="string" VALUE="Program Tree" />
<ARRAY NAME="GroupNameProgram Tree0" TYPE="string">
<A VALUE="SLUS_002.68" />
</ARRAY>
<STATE NAME="NavigationToggleState" TYPE="boolean" VALUE="false" />
<STATE NAME="NumberOfGroupsProgram Tree" TYPE="int" VALUE="1" />
<STATE NAME="NumberOfViews" TYPE="int" VALUE="1" />
<STATE NAME="TreeName-0" TYPE="string" VALUE="Program Tree" />
</PLUGIN>
<PLUGIN NAME="DecompilePlugin">
<STATE NAME="INDEX" TYPE="int" VALUE="0" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365060" />
<STATE NAME="Num Disconnected" TYPE="int" VALUE="0" />
<STATE NAME="Y_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.FunctionReturnTypeFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_FUNC_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_RETURN_TYPE" TYPE="string" VALUE="undefined" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<STATE NAME="_SIGNATURE" TYPE="string" VALUE="undefined main()" />
</PLUGIN>
<PLUGIN NAME="ByteViewerPlugin">
<STATE NAME="Block Column" TYPE="int" VALUE="0" />
<STATE NAME="Block Num" TYPE="int" VALUE="0" />
<STATE NAME="Block Offset" TYPE="string" VALUE="0" />
<STATE NAME="Index" TYPE="int" VALUE="0" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365062" />
<STATE NAME="Num Disconnected" TYPE="int" VALUE="0" />
<STATE NAME="X Offset" TYPE="int" VALUE="0" />
<STATE NAME="Y Offset" TYPE="int" VALUE="0" />
</PLUGIN>
<PLUGIN NAME="ProgramManagerPlugin">
<STATE NAME="CURRENT_FILE" TYPE="string" VALUE="SLUS_002.68" />
<STATE NAME="LOCATION_0" TYPE="string" VALUE="/K:/ghidra/Crusader_Decomp/" />
<STATE NAME="NUM_PROGRAMS" TYPE="int" VALUE="1" />
<STATE NAME="PATHNAME_0" TYPE="string" VALUE="/psx/prealpha/SLUS_002.68" />
<STATE NAME="PROJECT_NAME_0" TYPE="string" VALUE="Crusader" />
<STATE NAME="VERSION_0" TYPE="int" VALUE="-1" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.FunctionReturnTypeFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_FUNC_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_RETURN_TYPE" TYPE="string" VALUE="undefined" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<STATE NAME="_SIGNATURE" TYPE="string" VALUE="undefined main()" />
</PLUGIN>
<PLUGIN NAME="FunctionGraphPlugin">
<SAVE_STATE NAME="COMPLEX_LAYOUT_NAME" TYPE="SaveState">
<COMPLEX_LAYOUT_NAME>
<STATE NAME="LAYOUT_CLASS_NAME" TYPE="string" VALUE="ghidra.app.plugin.core.functiongraph.graph.layout.DecompilerNestedLayoutProvider" />
<STATE NAME="LAYOUT_NAME" TYPE="string" VALUE="Nested Code Layout" />
</COMPLEX_LAYOUT_NAME>
</SAVE_STATE>
<STATE NAME="DISPLAY_POPUPS" TYPE="boolean" VALUE="true" />
<STATE NAME="DISPLAY_SATELLITE" TYPE="boolean" VALUE="true" />
<STATE NAME="DOCK_SATELLITE" TYPE="boolean" VALUE="true" />
<STATE NAME="DOCK_SATELLITE_POSITION" TYPE="string" VALUE="LOWER_RIGHT" />
<STATE NAME="Disconnected Count" TYPE="int" VALUE="0" />
<ENUM NAME="EDGE_HOVER_HIGHLIGHT" TYPE="enum" CLASS="ghidra.app.plugin.core.functiongraph.EdgeDisplayType" VALUE="ScopedFlowsFromVertex" />
<ENUM NAME="EDGE_SELECTION_HIGHLIGHT" TYPE="enum" CLASS="ghidra.app.plugin.core.functiongraph.EdgeDisplayType" VALUE="AllCycles" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732731373338" />
</PLUGIN>
<PLUGIN NAME="CodeBrowserPlugin">
<STATE NAME="INDEX" TYPE="int" VALUE="193457" />
<STATE NAME="NAV_ID" TYPE="long" VALUE="3722070732148365069" />
<STATE NAME="Num Disconnected" TYPE="int" VALUE="0" />
<STATE NAME="Y_OFFSET" TYPE="int" VALUE="-11" />
<STATE NAME="_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_BYTE_ADDR" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_CHAR_OFFSET" TYPE="int" VALUE="0" />
<STATE NAME="_CLASSNAME" TYPE="string" VALUE="ghidra.program.util.FunctionReturnTypeFieldLocation" />
<STATE NAME="_COLUMN" TYPE="int" VALUE="0" />
<STATE NAME="_FUNC_ADDRESS" TYPE="string" VALUE="8002deb0" />
<STATE NAME="_RETURN_TYPE" TYPE="string" VALUE="undefined" />
<STATE NAME="_ROW" TYPE="int" VALUE="0" />
<STATE NAME="_SIGNATURE" TYPE="string" VALUE="undefined main()" />
</PLUGIN>
</DATA_STATE>
</RUNNING_TOOL>
</WORKSPACE>
</TOOL_MANAGER> </TOOL_MANAGER>
</PROJECT> </PROJECT>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<FILE_INFO>
<BASIC_INFO>
<STATE NAME="CONTENT_TYPE" TYPE="string" VALUE="ProgramUserData" />
<STATE NAME="PARENT" TYPE="string" VALUE="/" />
<STATE NAME="FILE_ID" TYPE="string" VALUE="c0a86451c52b19721868788800" />
<STATE NAME="FILE_TYPE" TYPE="int" VALUE="0" />
<STATE NAME="READ_ONLY" TYPE="boolean" VALUE="false" />
<STATE NAME="NAME" TYPE="string" VALUE="udf_c0a86451c52a19721794868600" />
</BASIC_INFO>
</FILE_INFO>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<FILE_INFO>
<BASIC_INFO>
<STATE NAME="CONTENT_TYPE" TYPE="string" VALUE="ProgramUserData" />
<STATE NAME="PARENT" TYPE="string" VALUE="/" />
<STATE NAME="FILE_ID" TYPE="string" VALUE="c0a86451da1b44983455342000" />
<STATE NAME="FILE_TYPE" TYPE="int" VALUE="0" />
<STATE NAME="READ_ONLY" TYPE="boolean" VALUE="false" />
<STATE NAME="NAME" TYPE="string" VALUE="udf_c0a86451da1a44983408575900" />
</BASIC_INFO>
</FILE_INFO>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4,6 +4,7 @@ VERSION=1
0000000c:udf_c0a86451c285202638031072100:c0a86451fcd2547353220145000 0000000c:udf_c0a86451c285202638031072100:c0a86451fcd2547353220145000
00000008:udf_c0a86451c28c202638381579400:c0a86451f608205075819887000 00000008:udf_c0a86451c28c202638381579400:c0a86451f608205075819887000
0000000b:udf_c0a86451c28e202638509414500:ac18b01ab332438409229485800 0000000b:udf_c0a86451c28e202638509414500:ac18b01ab332438409229485800
0000000d:udf_c0a86451c52a19721794868600:c0a86451c52b19721868788800
00000006:udf_c0a86451f2583322595358500:c0a86451c1883616844258300 00000006:udf_c0a86451f2583322595358500:c0a86451c1883616844258300
00000009:udf_c0a86451f6e9206725659389900:c0a86451ed04206884877489100 00000009:udf_c0a86451f6e9206725659389900:c0a86451ed04206884877489100
00000007:udf_c0a86451fc492611033559300:c0a86451cb1215992032385300 00000007:udf_c0a86451fc492611033559300:c0a86451cb1215992032385300
@ -13,5 +14,5 @@ VERSION=1
00000000:udf_c0a8647bf0178892741854800:c0a8647bd36236342207469100 00000000:udf_c0a8647bf0178892741854800:c0a8647bd36236342207469100
00000001:udf_c0a8647bf4b212984786819600:c0a8647bd36336342224113900 00000001:udf_c0a8647bf4b212984786819600:c0a8647bd36336342224113900
00000003:udf_c0a8647bfe7615910786193500:c0a8647bd36536342248279100 00000003:udf_c0a8647bfe7615910786193500:c0a8647bd36536342248279100
NEXT-ID:d NEXT-ID:e
MD5:d41d8cd98f00b204e9800998ecf8427e MD5:d41d8cd98f00b204e9800998ecf8427e

View file

@ -0,0 +1,2 @@
IADD:0000000e:/udf_c0a86451da1a44983408575900
IDSET:/udf_c0a86451da1a44983408575900:c0a86451da1b44983455342000

View file

@ -17,6 +17,15 @@
}, },
{ {
"path": "../Crusader_Decomp_Public" "path": "../Crusader_Decomp_Public"
},
{
"path": "E:/emu/psx/Crusader - No Remorse"
},
{
"path": "../crusader_map_viewer"
},
{
"path": "E:/emu/psx/Crusader 2 Pre-Pre Alpha"
} }
], ],
"settings": { "settings": {

2
__extract_l0.log Normal file
View file

@ -0,0 +1,2 @@
usage: psx_extract_wdl.py [-h] [--output OUTPUT] input
psx_extract_wdl.py: error: unrecognized arguments: E:\emu\psx\Crusader - No Remorse\LSET1\L0.WDL --output-root out/psx_wdl

22
__psx_extract_l0_run.log Normal file
View file

@ -0,0 +1,22 @@
file: E:\emu\psx\Crusader - No Remorse\LSET1\L0.WDL
kind: lset
header_size: 0x34
audio_size: 0x6FDC
post_audio_start: 0x7010
high_offset_boundaries: 0x7448, 0x34B6C, 0x72EC4, 0x7407C
regions:
audio_or_spu_blob: offset=0x34 size=0x6FDC tims=0
post_audio_region_00: offset=0x7010 size=0x438 tims=0
post_audio_region_01: offset=0x7448 size=0x2D724 tims=0
post_audio_region_02: offset=0x34B6C size=0x3E358 tims=0
post_audio_region_03: offset=0x72EC4 size=0x11B8 tims=0
post_audio_region_04: offset=0x7407C size=0xCC6F4 tims=1
tim_hits:
offset=0xBBA54 size=0x47 flags=0x0
sprite_bundles: 159
offset=0xE9168 mode=1 frames=2 data_offset=0x5C
offset=0x86810 mode=2 frames=1 data_offset=0x48
offset=0xA9D4C mode=2 frames=1 data_offset=0x48
offset=0x8813C mode=2 frames=1 data_offset=0x48
offset=0x922D8 mode=2 frames=1 data_offset=0x48
offset=0x93D40 mode=2 frames=1 data_offset=0x48

314
_tmp_psx_gpu_search.py Normal file
View file

@ -0,0 +1,314 @@
from __future__ import annotations
import bisect
import json
import struct
import sys
from pathlib import Path
ROOT = Path(r"k:/ghidra/Crusader_Decomp")
BUNDLE_DIR = ROOT / "out/psx_wdl/L0/sprite_bundles/bundle_000A1B04"
FRAME_PATH = BUNDLE_DIR / "frame_000.bin"
BUNDLE_JSON = BUNDLE_DIR / "bundle.json"
GPU_PATH = ROOT / "binary/Crusader - No Remorse (USA) GPU RAM.bin"
L0_WDL_PATH = Path(r"e:/emu/psx/Crusader - No Remorse/LSET1/L0.WDL")
ROW_BYTES = 2048
GPU_ROWS = 512
TOP_N = 10
FRAMEBUFFER_WIDTH = 320
FRAMEBUFFER_HEIGHT = 240
MATCH_TOP_N = 12
sys.path.insert(0, str(ROOT / "tools"))
from psx_extract_wdl import colorize_indexed_pixels, psx_555_to_rgba, write_overview_grid, write_psx_16bpp_png
def find_all(haystack: bytes, needle: bytes):
start = 0
while True:
index = haystack.find(needle, start)
if index < 0:
return
yield index
start = index + 1
def count_row_mismatches(left: bytes, right: bytes) -> int:
return sum(a != b for a, b in zip(left, right))
def is_exact_at(rows: list[bytes], candidate_rows: list[bytes], x: int, y: int, width: int) -> bool:
for dy, src in enumerate(candidate_rows):
if rows[y + dy][x : x + width] != src:
return False
return True
def near_score(
rows: list[bytes],
candidate_rows: list[bytes],
x: int,
y: int,
width: int,
cutoff: int | None,
) -> tuple[int, list[int], bool]:
total = 0
row_mismatches: list[int] = []
for dy, src in enumerate(candidate_rows):
seg = rows[y + dy][x : x + width]
mismatch = 0 if seg == src else count_row_mismatches(seg, src)
total += mismatch
row_mismatches.append(mismatch)
if cutoff is not None and total > cutoff:
return total, row_mismatches, True
return total, row_mismatches, False
def rgba_from_words(words: tuple[int, ...]) -> list[tuple[int, int, int]]:
return [psx_555_to_rgba(word)[:3] for word in words]
def candidate_match_score(
framebuffer_rgb: list[tuple[int, int, int]],
framebuffer_width: int,
framebuffer_height: int,
rgba: bytes,
width: int,
height: int,
guess_x: int,
guess_y: int,
radius: int = 12,
step: int = 2,
) -> tuple[int, int, int]:
best_score: int | None = None
best_x = -1
best_y = -1
x_min = max(0, guess_x - radius)
x_max = min(framebuffer_width - width, guess_x + radius)
y_min = max(0, guess_y - radius)
y_max = min(framebuffer_height - height, guess_y + radius)
for y in range(y_min, y_max + 1):
for x in range(x_min, x_max + 1):
score = 0
samples = 0
for sy in range(0, height, step):
screen_row = (y + sy) * framebuffer_width
sprite_row = sy * width * 4
for sx in range(0, width, step):
src = sprite_row + sx * 4
if rgba[src + 3] == 0:
continue
screen_r, screen_g, screen_b = framebuffer_rgb[screen_row + x + sx]
red = rgba[src]
green = rgba[src + 1]
blue = rgba[src + 2]
score += abs(screen_r - red) + abs(screen_g - green) + abs(screen_b - blue)
samples += 1
if samples == 0:
continue
normalized = score // samples
if best_score is None or normalized < best_score:
best_score = normalized
best_x = x
best_y = y
if best_score is None:
return 1 << 30, -1, -1
return best_score, best_x, best_y
def main() -> None:
bundle = json.loads(BUNDLE_JSON.read_text(encoding="ascii"))
frame_meta = next(frame for frame in bundle["exported_frames"] if frame["index"] == 0)
width = frame_meta["width"]
height = frame_meta["height"]
mode = bundle["mode"]
frame = FRAME_PATH.read_bytes()
expected = width * height
if len(frame) != expected:
raise SystemExit(f"frame byte size mismatch: got {len(frame)}, expected {expected}")
if mode != 1:
raise SystemExit(f"unexpected mode {mode}, expected 1 for 8bpp")
gpu = GPU_PATH.read_bytes()
if len(gpu) != ROW_BYTES * GPU_ROWS:
raise SystemExit(f"unexpected GPU dump size {len(gpu)}")
l0_data = L0_WDL_PATH.read_bytes()
palette_offset = int.from_bytes(l0_data[8:12], "little")
palette_size = int.from_bytes(l0_data[12:16], "little")
if palette_size != 0x1000:
raise SystemExit(f"unexpected palette size 0x{palette_size:X}")
palette_blob = l0_data[palette_offset : palette_offset + palette_size]
palettes_256 = [palette_blob[offset : offset + 0x200] for offset in range(0, len(palette_blob), 0x200)]
rows = [gpu[y * ROW_BYTES : (y + 1) * ROW_BYTES] for y in range(GPU_ROWS)]
frame_rows = [frame[i * width : (i + 1) * width] for i in range(height)]
flip_rows = [row[::-1] for row in frame_rows]
normal_hits: list[tuple[int, int]] = []
flipped_hits: list[tuple[int, int]] = []
for y in range(GPU_ROWS - height + 1):
row = rows[y]
normal_hits.extend((x, y) for x in find_all(row, frame_rows[0]))
flipped_hits.extend((x, y) for x in find_all(row, flip_rows[0]))
exact_normal = [(x, y) for x, y in normal_hits if is_exact_at(rows, frame_rows, x, y, width)]
exact_flipped = [(x, y) for x, y in flipped_hits if is_exact_at(rows, flip_rows, x, y, width)]
print(f"bundle_offset=0x{bundle['offset']:X} mode={mode} frame_count={bundle['frame_count']}")
print(
"frame0 "
f"width={width} height={height} origin=({frame_meta['origin_x']},{frame_meta['origin_y']}) "
f"data_start={frame_meta['data_start']} consumed={frame_meta['consumed']}"
)
print(f"frame_bytes={len(frame)} gpu_dump_bytes={len(gpu)}")
print(f"row0_hits normal={len(normal_hits)} flipped={len(flipped_hits)}")
print(f"exact_full_matches_normal={len(exact_normal)}")
for x, y in exact_normal[:TOP_N]:
print(f" normal x={x} y={y} page=({x // 256},{y // 256}) in_page=({x % 256},{y % 256})")
print(f"exact_full_matches_flipped={len(exact_flipped)}")
for x, y in exact_flipped[:TOP_N]:
print(f" flipped x={x} y={y} page=({x // 256},{y // 256}) in_page=({x % 256},{y % 256})")
live_palette_entries: list[dict[str, object]] = []
live_palette_labels: list[str] = []
for clut_row in range(8):
y = 0xF0 + clut_row
row_words = struct.unpack("<1024H", rows[y])
for column in range(16):
x = column * 16
palette = list(row_words[x : x + 256])
rgba = colorize_indexed_pixels(frame, width, height, mode, palette)
live_palette_entries.append(
{
"width": width,
"height": height,
"rgba": rgba,
}
)
live_palette_labels.append(f"index={clut_row * 16 + column} x={x} y={y}")
atlas_path = BUNDLE_DIR / "live_vram_clut_atlas.png"
labels_path = BUNDLE_DIR / "live_vram_clut_atlas.txt"
write_overview_grid(atlas_path, live_palette_entries, columns=16)
labels_path.write_text("\n".join(live_palette_labels) + "\n", encoding="ascii")
print(f"live_vram_clut_atlas={atlas_path}")
print(f"live_vram_clut_labels={labels_path}")
framebuffer_path = ROOT / "binary/psx_framebuffer_left.png"
framebuffer_crop_path = ROOT / "binary/psx_framebuffer_console_crop.png"
print(f"raw_palette_blocks_256={len(palettes_256)}")
for palette_index, palette in enumerate(palettes_256):
palette_hits: list[tuple[int, int]] = []
for y in range(240, 256):
row = rows[y]
start = 0
while True:
x = row.find(palette, start)
if x < 0:
break
palette_hits.append((x, y))
start = x + 1
print(f" palette_{palette_index}_hits={len(palette_hits)}")
for x, y in palette_hits[:TOP_N]:
print(f" palette_{palette_index} x={x} y={y} row_band={y - 240}")
framebuffer_bytes = bytearray(FRAMEBUFFER_WIDTH * FRAMEBUFFER_HEIGHT * 2)
for y in range(FRAMEBUFFER_HEIGHT):
src_row = rows[y]
start = y * FRAMEBUFFER_WIDTH * 2
framebuffer_bytes[start : start + FRAMEBUFFER_WIDTH * 2] = src_row[: FRAMEBUFFER_WIDTH * 2]
write_psx_16bpp_png(framebuffer_path, bytes(framebuffer_bytes), FRAMEBUFFER_WIDTH, FRAMEBUFFER_HEIGHT)
framebuffer_words = struct.unpack(f"<{FRAMEBUFFER_WIDTH * FRAMEBUFFER_HEIGHT}H", bytes(framebuffer_bytes))
framebuffer_rgb = rgba_from_words(framebuffer_words)
crop_x = 70
crop_y = 0
crop_width = 210
crop_height = 110
crop_bytes = bytearray(crop_width * crop_height * 2)
for y in range(crop_height):
src = rows[crop_y + y]
src_start = crop_x * 2
src_end = src_start + crop_width * 2
dst_start = y * crop_width * 2
crop_bytes[dst_start : dst_start + crop_width * 2] = src[src_start:src_end]
write_psx_16bpp_png(framebuffer_crop_path, bytes(crop_bytes), crop_width, crop_height)
print(f"framebuffer_left={framebuffer_path}")
print(f"framebuffer_console_crop={framebuffer_crop_path}")
palette_rankings: list[tuple[int, int, int, int]] = []
for palette_index, entry in enumerate(live_palette_entries):
score, best_x, best_y = candidate_match_score(
framebuffer_rgb,
FRAMEBUFFER_WIDTH,
FRAMEBUFFER_HEIGHT,
entry["rgba"],
width,
height,
guess_x=107,
guess_y=12,
)
palette_rankings.append((score, palette_index, best_x, best_y))
palette_rankings.sort()
ranking_path = BUNDLE_DIR / "live_vram_clut_rank.txt"
top_atlas_path = BUNDLE_DIR / "live_vram_clut_top_matches.png"
best_candidate_path = BUNDLE_DIR / "live_vram_clut_best.png"
ranking_lines = []
print(f"best_live_vram_clut_matches_top_{MATCH_TOP_N}={min(MATCH_TOP_N, len(palette_rankings))}")
top_entries: list[dict[str, object]] = []
for score, palette_index, best_x, best_y in palette_rankings[:MATCH_TOP_N]:
label = live_palette_labels[palette_index]
line = f"score={score} {label} screen=({best_x},{best_y})"
ranking_lines.append(line)
print(f" {line}")
top_entries.append(live_palette_entries[palette_index])
ranking_path.write_text("\n".join(ranking_lines) + "\n", encoding="ascii")
print(f"live_vram_clut_rank={ranking_path}")
write_overview_grid(top_atlas_path, top_entries, columns=4)
print(f"live_vram_clut_top_matches={top_atlas_path}")
if palette_rankings:
best_palette_index = palette_rankings[0][1]
best_entry = live_palette_entries[best_palette_index]
write_overview_grid(best_candidate_path, [best_entry], columns=1)
print(f"live_vram_clut_best={best_candidate_path}")
if exact_normal or exact_flipped:
return
ranked: list[tuple[int, int, int, str, list[int]]] = []
cutoff: int | None = None
for orientation, hits, candidate_rows in (
("normal", normal_hits, frame_rows),
("flipped", flipped_hits, flip_rows),
):
for x, y in hits:
total, row_mismatches, pruned = near_score(rows, candidate_rows, x, y, width, cutoff)
if pruned and len(ranked) >= TOP_N and total > ranked[-1][0]:
continue
entry = (total, y, x, orientation, row_mismatches)
insert_at = bisect.bisect_left(ranked, entry)
ranked.insert(insert_at, entry)
if len(ranked) > TOP_N:
ranked.pop()
if len(ranked) == TOP_N:
cutoff = ranked[-1][0]
print(f"best_near_matches_top_{TOP_N}={len(ranked)}")
for total, y, x, orientation, row_mismatches in ranked:
nonzero_rows = [(index, mismatch) for index, mismatch in enumerate(row_mismatches) if mismatch]
sample = ", ".join(f"r{index}={mismatch}" for index, mismatch in nonzero_rows[:8])
if len(nonzero_rows) > 8:
sample += ", ..."
if not sample:
sample = "all rows exact"
print(
f" {orientation} x={x} y={y} page=({x // 256},{y // 256}) in_page=({x % 256},{y % 256}) "
f"mismatches={total} details=[{sample}]"
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,134 @@
from __future__ import annotations
import json
import struct
import sys
from pathlib import Path
ROOT = Path(r"k:/ghidra/Crusader_Decomp")
L0_WDL_PATH = Path(r"e:/emu/psx/Crusader - No Remorse/LSET1/L0.WDL")
GPU_PATH = ROOT / "binary/Crusader - No Remorse (USA) GPU RAM.bin"
OUTPUT_DIR = ROOT / "out/psx_wdl/L0/mode1_live_clut_row_f0_x0"
ROW_BYTES = 2048
LIVE_CLUT_Y = 0xF0
LIVE_CLUT_X = 0
sys.path.insert(0, str(ROOT / "tools"))
from psx_extract_wdl import (
colorize_indexed_pixels,
parse_lset_wdl,
scan_sprite_bundles,
write_bundle_atlas,
write_overview_grid,
write_png_rgba,
)
def main() -> None:
l0_data = L0_WDL_PATH.read_bytes()
gpu = GPU_PATH.read_bytes()
summary = parse_lset_wdl(l0_data)
if summary is None:
raise SystemExit("failed to parse L0.WDL")
region = next(region for region in summary["regions"] if region["name"] == "post_audio_region_04")
region_data = l0_data[region["offset"] : region["offset"] + region["size"]]
bundles = scan_sprite_bundles(region_data, max_candidates=160)
row = gpu[LIVE_CLUT_Y * ROW_BYTES : (LIVE_CLUT_Y + 1) * ROW_BYTES]
row_words = struct.unpack("<1024H", row)
palette = list(row_words[LIVE_CLUT_X : LIVE_CLUT_X + 256])
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
entries: list[dict[str, object]] = []
summary_rows: list[dict[str, object]] = []
mode1_count = 0
for bundle in bundles:
if bundle["mode"] != 1 or not bundle["frames"]:
continue
mode1_count += 1
bundle_dir = OUTPUT_DIR / f"bundle_{bundle['offset']:08X}"
bundle_dir.mkdir(parents=True, exist_ok=True)
rendered_frames: list[dict[str, object]] = []
frame_rows: list[dict[str, object]] = []
for frame in bundle["frames"]:
rgba = colorize_indexed_pixels(frame["pixels"], frame["width"], frame["height"], bundle["mode"], palette)
write_png_rgba(bundle_dir / f"frame_{frame['index']:03d}_live_row_f0_x0.png", rgba, frame["width"], frame["height"])
rendered_frames.append(
{
"width": frame["width"],
"height": frame["height"],
"rgba": rgba,
}
)
frame_rows.append(
{
"index": frame["index"],
"width": frame["width"],
"height": frame["height"],
"origin_x": frame["origin_x"],
"origin_y": frame["origin_y"],
"data_start": frame["data_start"],
"consumed": frame["consumed"],
}
)
write_bundle_atlas(bundle_dir / "atlas_live_row_f0_x0.png", rendered_frames)
metadata = {
"offset": bundle["offset"],
"mode": bundle["mode"],
"palette_formula": "live_gpu_row_0xF0_x0_contiguous_256",
"palette_source": {
"gpu_dump": str(GPU_PATH),
"x": LIVE_CLUT_X,
"y": LIVE_CLUT_Y,
},
"frame_count": bundle["frame_count"],
"exported_frames": frame_rows,
}
(bundle_dir / "palette_formula.json").write_text(json.dumps(metadata, indent=2), encoding="ascii")
first_frame = bundle["frames"][0]
first_rgba = rendered_frames[0]["rgba"]
entries.append(
{
"width": first_frame["width"],
"height": first_frame["height"],
"rgba": first_rgba,
"offset": bundle["offset"],
"area": first_frame["width"] * first_frame["height"],
}
)
summary_rows.append(
{
"offset": bundle["offset"],
"width": first_frame["width"],
"height": first_frame["height"],
"frame_count": bundle["frame_count"],
}
)
entries.sort(key=lambda entry: entry["area"], reverse=True)
overview_entries = [{"width": entry["width"], "height": entry["height"], "rgba": entry["rgba"]} for entry in entries]
write_overview_grid(OUTPUT_DIR / "overview_live_row_f0_x0.png", overview_entries, columns=4)
summary_rows.sort(key=lambda row: row["width"] * row["height"], reverse=True)
(OUTPUT_DIR / "summary.json").write_text(
json.dumps(
{
"palette_formula": "live_gpu_row_0xF0_x0_contiguous_256",
"mode1_bundle_count": mode1_count,
"bundles": summary_rows,
},
indent=2,
),
encoding="ascii",
)
print(f"mode1_bundles={mode1_count}")
print(f"overview={OUTPUT_DIR / 'overview_live_row_f0_x0.png'}")
print(f"summary={OUTPUT_DIR / 'summary.json'}")
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View file

@ -4,6 +4,10 @@ This file is an index. Detailed notes have been split into the `docs/` folder by
Active live analysis target is now `CRUSADER.EXE`. Existing `CRUSADER-RAW.EXE` notes remain in scope as cross-reference evidence and should be cited alongside live NE addresses when they support a rename, variable role, or behavior claim. Active live analysis target is now `CRUSADER.EXE`. Existing `CRUSADER-RAW.EXE` notes remain in scope as cross-reference evidence and should be cited alongside live NE addresses when they support a rename, variable role, or behavior claim.
Recent verified PSX pre-alpha batch: [docs/psx/prealpha.md](docs/psx/prealpha.md) now records a focused Ghidra pass on `/psx/prealpha/SLUS_002.68` plus a disc-tree comparison against the released PlayStation `Crusader: No Remorse` build. Current best read is that this pre-pre alpha still looks much more like a trimmed early No Remorse PSX branch than a clearly rebranded `Crusader 2` executable: it still carries direct `Crusader: No Remorse` save/quit text, the renamed `wdl_resource_bundle_load_by_index` still embeds the full retail `\LSET1\L` through `\LSET7\L` prefix table and the same `10/20/30/40/50/60` threshold ladder, and the mission/passcode UI scaffolding is still present with the same visible `15` mission briefing strings and consonant/digit passcode alphabet. The main concrete differences in this batch are the heavily reduced shipped content (`3` level bundles, `1` XA, no `.STR` movies) and the surviving architectural leftovers that no longer match the current disc literally, especially the missing-file `\AUDIO\TALK1.XA;1` path and the `LoadExec` helper for `MENU.EXE` / `ENGINE.EXE` / `PSX.EXE`.
Recent verified PSX executable batch: [docs/psx/psx.md](docs/psx/psx.md) now records a focused Ghidra pass on `SLUS_002.68` for mission/map inventory, passcode handling, and catalog text. Current best read is that the PSX loader hardcodes seven `\LSETn\L` folder prefixes and the extracted disc ships `62` level bundles (`L0..L58`, `L62..L64`) with a real gap at `L59..L61`, while the executable still exposes only `15` plain-text `Mission Briefing ^Mission N` strings. The same pass closes the visible passcode-generation side too: mission-complete flow synthesizes `4`-character passcodes from the alphabet `BCDFGHJKLMNPQRSTVWXZ0123456789`, and the executable preserves direct ammo/item/weapon name tables. The hidden password-screen cheat codes remain less direct: public PSX references point to `XXXX` and `L0SR`/`L0SER`, but those values are not stored as plain ASCII in `SLUS_002.68`, so the compare path still looks numeric or transformed rather than table-driven.
Recent verified Japanese-build batch: [docs/jp-remorse-windows9x-investigation.md](docs/jp-remorse-windows9x-investigation.md) now records a focused live-Ghidra investigation of `/ja/CRUSADER.EXE` around the claim that the Japanese release runs natively on Windows 95 / Windows 9x instead of requiring a DOS boot path. Current best static-analysis read is strongly in favor: the JP executable is a flat Win32 image with PE-style sections, a Windows import table, native window creation, DirectDraw/DirectSound initialization, registry-backed config under `Software\Electronic Arts\Crusader: No Remorse\J1.21`, and a meaningful `GetVersion`-based Win9x compatibility branch that changes TLS allocation behavior when the classic Win9x high bit is set. The only remaining uncertainty is practical deployment rather than architecture: this pass did not runtime-test on real Win95 or prove which DirectX/runtime prerequisites are required. Recent verified Japanese-build batch: [docs/jp-remorse-windows9x-investigation.md](docs/jp-remorse-windows9x-investigation.md) now records a focused live-Ghidra investigation of `/ja/CRUSADER.EXE` around the claim that the Japanese release runs natively on Windows 95 / Windows 9x instead of requiring a DOS boot path. Current best static-analysis read is strongly in favor: the JP executable is a flat Win32 image with PE-style sections, a Windows import table, native window creation, DirectDraw/DirectSound initialization, registry-backed config under `Software\Electronic Arts\Crusader: No Remorse\J1.21`, and a meaningful `GetVersion`-based Win9x compatibility branch that changes TLS allocation behavior when the classic Win9x high bit is set. The only remaining uncertainty is practical deployment rather than architecture: this pass did not runtime-test on real Win95 or prove which DirectX/runtime prerequisites are required.
Recent verified Japanese-build follow-up: [docs/jp-remorse-cheats-and-launch-params.md](docs/jp-remorse-cheats-and-launch-params.md) now records a focused pass on the surviving cheat/debug and startup-argument lanes in `/ja/CRUSADER.EXE`. Current best read is that the JP Win32 build kept real executable cheat/debug machinery, not just leftover strings: `-laurie` is still a special parser case, the hidden `JASSICA16` sequence matcher still toggles the cheat-active state with live `Cheats are now active/inactive.` messages, the option-key handler still contains the immortality toggle path, and the command-line parser still executes live handlers for `-debug`, `-u <arg>`, `-warp <mission>`, `-skill <n>`, `-mapoff <delta>`, `-egg <id>`, and `-demo`. The same pass also narrows one important difference from older DOS-side notes: the JP Win32 parser has not yet been proven to support positional `-warp <mission> <x> <y> <z>` consumption, so that form should not currently be assumed for this build. Recent verified Japanese-build follow-up: [docs/jp-remorse-cheats-and-launch-params.md](docs/jp-remorse-cheats-and-launch-params.md) now records a focused pass on the surviving cheat/debug and startup-argument lanes in `/ja/CRUSADER.EXE`. Current best read is that the JP Win32 build kept real executable cheat/debug machinery, not just leftover strings: `-laurie` is still a special parser case, the hidden `JASSICA16` sequence matcher still toggles the cheat-active state with live `Cheats are now active/inactive.` messages, the option-key handler still contains the immortality toggle path, and the command-line parser still executes live handlers for `-debug`, `-u <arg>`, `-warp <mission>`, `-skill <n>`, `-mapoff <delta>`, `-egg <id>`, and `-demo`. The same pass also narrows one important difference from older DOS-side notes: the JP Win32 parser has not yet been proven to support positional `-warp <mission> <x> <y> <z>` consumption, so that form should not currently be assumed for this build.
@ -24,6 +28,8 @@ New command-line argument batch: [docs/command-line-parameters.md](docs/command-
Follow-up No Remorse cross-check: the same command-line note and [docs/first-mission-map-selection.md](docs/first-mission-map-selection.md) now record the matching live `CRUSADER.EXE` proof. `HandleCommandlineArgs` at `1048:0adc` uses the same positional `-warp <mission> [x y z]` syntax as Regret, and `Game_Start` at `1020:029e` / `1020:02d0` applies the same precedence rule where nonnegative `-egg` overrides beat the direct-coordinate `NPC_Teleport` path. Follow-up No Remorse cross-check: the same command-line note and [docs/first-mission-map-selection.md](docs/first-mission-map-selection.md) now record the matching live `CRUSADER.EXE` proof. `HandleCommandlineArgs` at `1048:0adc` uses the same positional `-warp <mission> [x y z]` syntax as Regret, and `Game_Start` at `1020:029e` / `1020:02d0` applies the same precedence rule where nonnegative `-egg` overrides beat the direct-coordinate `NPC_Teleport` path.
Latest warp-table follow-up: the same [docs/first-mission-map-selection.md](docs/first-mission-map-selection.md) and [docs/regret-game-start.md](docs/regret-game-start.md) notes now close the missing No Regret table details directly. Live `REGRET.EXE` `Game_RunNewGameFlow` indexes the `-warp mission` base-map table at `1480:075c`, and retail byte checks now show the same 17-word payload as No Remorse: `0,1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,40`, followed by a `0,0` terminator. The public renderer project now also has a dedicated extractor that writes both retail tables into `Crusader_Decomp_Public/map_renderer/.cache/mission-map-data.generated.json` for scene-metadata use.
Latest command-line follow-up: that same [docs/command-line-parameters.md](docs/command-line-parameters.md) note now closes the retail non-Japanese `-u` lane as well. In live `CRUSADER.EXE`, the parser case at `1048:0a46` copies the following token into `1478:065a`, and the newly named `startup_apply_u_override_if_present` at `1420:0cdf` consumes that buffer to load an alternate usecode/EUSECODE source into `1478:6611/6613` before rebuilding the cumulative slot-base words. Current best read is therefore `real startup usecode override`, not `JP-only feature` and not `dead parser-table residue`. The same follow-up also means the older consolidated `-setver` note is now weaker on the CRUSADER side and should be treated as needing a direct retail re-close. Latest command-line follow-up: that same [docs/command-line-parameters.md](docs/command-line-parameters.md) note now closes the retail non-Japanese `-u` lane as well. In live `CRUSADER.EXE`, the parser case at `1048:0a46` copies the following token into `1478:065a`, and the newly named `startup_apply_u_override_if_present` at `1420:0cdf` consumes that buffer to load an alternate usecode/EUSECODE source into `1478:6611/6613` before rebuilding the cumulative slot-base words. Current best read is therefore `real startup usecode override`, not `JP-only feature` and not `dead parser-table residue`. The same follow-up also means the older consolidated `-setver` note is now weaker on the CRUSADER side and should be treated as needing a direct retail re-close.
Latest `-u` deep dive: new note [docs/usecode-startup-override.md](docs/usecode-startup-override.md) now follows that retail override into the live usecode runtime itself. Current best read is that `-u` replaces the single live usecode root at `1478:6611/6613` rather than adding a parallel overlay. The same root is later consumed by `Usecode_ItemCallEvent`, `UsecodeProcess_CreateProcess`, `Interpreter_NextUsecodeOp`, and `Item_GetDamaged`, so the override reaches ordinary scripted gameplay behavior, not just a startup-only side lane. Current safest tooling implication is `runtime replacement for the existing Crusader usecode VM`, not `arbitrary native plug-in system`. Latest `-u` deep dive: new note [docs/usecode-startup-override.md](docs/usecode-startup-override.md) now follows that retail override into the live usecode runtime itself. Current best read is that `-u` replaces the single live usecode root at `1478:6611/6613` rather than adding a parallel overlay. The same root is later consumed by `Usecode_ItemCallEvent`, `UsecodeProcess_CreateProcess`, `Interpreter_NextUsecodeOp`, and `Item_GetDamaged`, so the override reaches ordinary scripted gameplay behavior, not just a startup-only side lane. Current safest tooling implication is `runtime replacement for the existing Crusader usecode VM`, not `arbitrary native plug-in system`.
@ -67,6 +73,8 @@ The same `docs/ne-segment1.md` note now also has the first consolidated cheat/de
| [docs/first-mission-map-selection.md](docs/first-mission-map-selection.md) | Focused note on fresh-game startup map selection: No Remorse `Game_Start`, No Regret's early and later mission-start selectors, the separate embedded `-warp mission` table, and the split between code-selected startup and external `FIXED.DAT` map content | | [docs/first-mission-map-selection.md](docs/first-mission-map-selection.md) | Focused note on fresh-game startup map selection: No Remorse `Game_Start`, No Regret's early and later mission-start selectors, the separate embedded `-warp mission` table, and the split between code-selected startup and external `FIXED.DAT` map content |
| [docs/regret-game-start.md](docs/regret-game-start.md) | Detailed `REGRET.EXE` startup-flow note: `Game_Start`, `Game_RunNewGameFlow`, newly named helpers, startup override globals, and the current best explanation for the duplicated map-1 selector | | [docs/regret-game-start.md](docs/regret-game-start.md) | Detailed `REGRET.EXE` startup-flow note: `Game_Start`, `Game_RunNewGameFlow`, newly named helpers, startup override globals, and the current best explanation for the duplicated map-1 selector |
| [docs/command-line-parameters.md](docs/command-line-parameters.md) | Consolidated startup/debug argument reference for the retail Crusader executables: live retail `-u` usecode override, the current `-setver` caution, `-debug`, `-asylum`, `-warp`, `-skill`, `-mapoff`, `-egg`, `-demo`, the `-laurie` cross-reference, and the evidence-backed direct-coordinate warp syntax/limits | | [docs/command-line-parameters.md](docs/command-line-parameters.md) | Consolidated startup/debug argument reference for the retail Crusader executables: live retail `-u` usecode override, the current `-setver` caution, `-debug`, `-asylum`, `-warp`, `-skill`, `-mapoff`, `-egg`, `-demo`, the `-laurie` cross-reference, and the evidence-backed direct-coordinate warp syntax/limits |
| [docs/psx/psx.md](docs/psx/psx.md) | PlayStation `SLUS_002.68` and disc-resource note: boot/load layout, `LSET`/menu WDL structure, executable-backed map inventory, passcode alphabet/display path, recovered PSX ammo/item/weapon tables, and current unresolved enemy/password-compare gaps |
| [docs/psx/prealpha.md](docs/psx/prealpha.md) | PlayStation pre-pre alpha `/psx/prealpha/SLUS_002.68` comparison note: reduced disc inventory, retained retail-style `LSET` loader, surviving No Remorse branding, stale `TALK1.XA` and `LoadExec` leftovers, and the current read that this build is closer to an unfinished No Remorse PSX branch than to a visibly rebranded sequel executable |
| [docs/usecode-startup-override.md](docs/usecode-startup-override.md) | Focused retail `-u` deep dive: startup call order, why the override looks like full live-root replacement rather than addition, which event/process/interpreter consumers use that root, and what that implies for future custom usecode experiments | | [docs/usecode-startup-override.md](docs/usecode-startup-override.md) | Focused retail `-u` deep dive: startup call order, why the override looks like full live-root replacement rather than addition, which event/process/interpreter consumers use that root, and what that implies for future custom usecode experiments |
| [docs/usecode-roundtrip-ir.md](docs/usecode-roundtrip-ir.md) | ScummVM-to-binary USECODE cross-walk, owner-loaded class-layout and header/event-count reconciliation, conservative IR v0 plan, and the generated class-event/body-window outputs that now ground reversible `_BOOT`, `SURCAM*`, and environmental family decompile artifacts plus repeated-family regression checks | | [docs/usecode-roundtrip-ir.md](docs/usecode-roundtrip-ir.md) | ScummVM-to-binary USECODE cross-walk, owner-loaded class-layout and header/event-count reconciliation, conservative IR v0 plan, and the generated class-event/body-window outputs that now ground reversible `_BOOT`, `SURCAM*`, and environmental family decompile artifacts plus repeated-family regression checks |
| [docs/usecode-pentagram-ghidra-path.md](docs/usecode-pentagram-ghidra-path.md) | Pentagram-derived Crusader USECODE parser plan, proof-of-concept workflow, canonical IR v1 goals, and the Ghidra-side annotation import path | | [docs/usecode-pentagram-ghidra-path.md](docs/usecode-pentagram-ghidra-path.md) | Pentagram-derived Crusader USECODE parser plan, proof-of-concept workflow, canonical IR v1 goals, and the Ghidra-side annotation import path |
@ -76,3 +84,4 @@ The same `docs/ne-segment1.md` note now also has the first consolidated cheat/de
| [docs/usecode-equipment-system.md](docs/usecode-equipment-system.md) | Evidence-backed note on Crusader's surviving `equip` / `unequip` event system, including live compiled-side dispatcher proof, corpus-wide slot counts, actor/turret/environment examples, and the current best model of `equip` as a generalized inherited Ultima-style item event | | [docs/usecode-equipment-system.md](docs/usecode-equipment-system.md) | Evidence-backed note on Crusader's surviving `equip` / `unequip` event system, including live compiled-side dispatcher proof, corpus-wide slot counts, actor/turret/environment examples, and the current best model of `equip` as a generalized inherited Ultima-style item event |
| [docs/usecode-alarmhat-analysis.md](docs/usecode-alarmhat-analysis.md) | Focused analysis of exported `ALARMHAT::equip`, the nearby `shape 0x04D0` equip loops, alarm-family comparisons, and the current gameplay-facing read of `ALARMHAT` as a local alarm-state driver | | [docs/usecode-alarmhat-analysis.md](docs/usecode-alarmhat-analysis.md) | Focused analysis of exported `ALARMHAT::equip`, the nearby `shape 0x04D0` equip loops, alarm-family comparisons, and the current gameplay-facing read of `ALARMHAT` as a local alarm-state driver |
| [docs/usecode/windsurf-regret-vs-remorse.md](docs/usecode/windsurf-regret-vs-remorse.md) | Side-by-side comparison of `WINDSURF` in Regret and No Remorse, including shared slot behavior, helper-family drift, body-size differences, and the current best read of `WINDSURF` as a directional wind-force helper used by vent scripts | | [docs/usecode/windsurf-regret-vs-remorse.md](docs/usecode/windsurf-regret-vs-remorse.md) | Side-by-side comparison of `WINDSURF` in Regret and No Remorse, including shared slot behavior, helper-family drift, body-size differences, and the current best read of `WINDSURF` as a directional wind-force helper used by vent scripts |
| [docs/removed_items.md](docs/removed_items.md) | Evidence summary for suspicious removed item shapes in old No Remorse maps: grenade-family leftovers `0343/034E/034F/0350`, the inventory-labeled `0548` `Invalid` item, and unresolved non-pickup shapes `0110/0112` |

View file

@ -199,6 +199,56 @@ So the `-warp mission` path also uses code/data embedded in the executable, not
`-mapoff` therefore matters only inside the manual/debug warp path. It does not affect the ordinary fresh-game selector when no `-warp` argument is present. `-mapoff` therefore matters only inside the manual/debug warp path. It does not affect the ordinary fresh-game selector when no `-warp` argument is present.
### 3a. No Regret cross-check: the live `REGRET.EXE` table is the same 17-word sequence at `1480:075c`
The currently opened `REGRET.EXE` session now closes the missing cross-check directly.
In `Game_RunNewGameFlow`, the debug/manual warp lane computes:
```c
mapno = *(int *)(g_warpToLevelNoArg * 2 + 0x75c) + DAT_1480_0ad0;
```
So the live No Regret table base is:
- `1480:075c`
The retail bytes at that address are:
```text
1480:075c: 00 00 01 00 03 00 05 00 07 00 09 00 0b 00 0d 00
1480:076c: 0f 00 11 00 13 00 15 00 17 00 19 00 1b 00 1d 00
1480:077c: 28 00
```
Interpreted as little-endian words, that is the same 17-entry base-map sequence already recovered in No Remorse:
- `0, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 40`
The next two words after the last entry are `0x0000, 0x0000`, so the retail table also has a clean double-zero terminator immediately after the `40` entry.
Current best cross-game read is therefore tighter than the earlier one-sided Remorse note:
- No Remorse and No Regret both keep the debug `-warp mission` base-map table in executable data
- the two currently checked retail tables carry the same 17-word payload
- the practical game-to-game difference in this startup area is not the mission table itself, but the surrounding startup control flow and data addresses
### 3b. Renderer-side extracted JSON now records both retail tables
The public renderer project now has a dedicated extractor for this table instead of keeping the mission mapping only in prose notes.
Current generator and output:
- script: `Crusader_Decomp_Public/map_renderer/src/generate-mission-map-data.js`
- generated cache file: `Crusader_Decomp_Public/map_renderer/.cache/mission-map-data.generated.json`
That JSON captures both retail tables, their data-segment addresses, the per-mission base-map entries, and the reverse `map -> missions` lookup used by the renderer metadata.
The renderer's scene metadata builder now consumes that generated table so usage notes can distinguish:
- maps that are real base mission entries in the executable `-warp` table
- maps that are only reachable through `-mapoff` or some other non-table selector path
Practical implication for the earlier patch question: Practical implication for the earlier patch question:
- if the goal was `start a normal new game on a different first map`, the executable patch was still required - if the goal was `start a normal new game on a different first map`, the executable patch was still required
@ -207,7 +257,7 @@ Practical implication for the earlier patch question:
The No Remorse cross-check now makes that last point stronger: this eggless-map workaround is not just a Regret quirk. `CRUSADER.EXE` uses the same parser shape and the same consumer-side precedence. The No Remorse cross-check now makes that last point stronger: this eggless-map workaround is not just a Regret quirk. `CRUSADER.EXE` uses the same parser shape and the same consumer-side precedence.
### 3a. Why `-warp 0 28670 30718 0 -mapoff 246` likely lands in a bad spot in No Remorse ### 3c. Why `-warp 0 28670 30718 0 -mapoff 246` likely lands in a bad spot in No Remorse
The current cached scene data from `Crusader_Decomp_Public/map_renderer/.cache/scene-cache/remorse/map-246/bb7e36195d39ac72/scene.json` confirms that map `246` is real and nonempty: The current cached scene data from `Crusader_Decomp_Public/map_renderer/.cache/scene-cache/remorse/map-246/bb7e36195d39ac72/scene.json` confirms that map `246` is real and nonempty:
@ -366,4 +416,75 @@ For No Regret, the patcher now updates both hardcoded selectors so the later mis
- No Regret hardcodes the same values twice: once in `Game_Start`, and again in the later `FUN_1030_032d` mission-start path that actually controls a real new game. - No Regret hardcodes the same values twice: once in `Game_Start`, and again in the later `FUN_1030_032d` mission-start path that actually controls a real new game.
- The debug `-warp mission` path uses an executable-embedded mission-to-map word table at `1478:0488`, plus `-mapoff`. - The debug `-warp mission` path uses an executable-embedded mission-to-map word table at `1478:0488`, plus `-mapoff`.
- External files such as `FIXED.DAT` hold the actual map contents. - External files such as `FIXED.DAT` hold the actual map contents.
- `CRUSADER.CFG` does not control which map a new game starts on. - `CRUSADER.CFG` does not control which map a new game starts on.
# Maps mapping by Candy
These entries might be shifted by 1
28: Rebel base
29: destroyed Rebel base
Mission 1:
0: level 1, looks complete
34: level 1, most complete
56: level 1
Mission 2:
2: mission 2
3: mission 2 incomplete
Mission 3:
4: thermatron manufacturing plant
38: thermatron manufacturing plant
43: thermatron manuf plant
45: thermatron manuf plant
5: thermatron plant broken
Mission 4:
6: blow up comms
44: incomplete mission 5
Mission 5:
8: first half
9: second half
Mission 6:
10:
Mission 7:
11: Blow up nerve gas stockpile
12: Blow up nerve gas stockpile
Mission 8: Save prof Willmar who dies on telepad
13,14: first mission with extra protection stuff, 5?
Mission 9:
15:
36:
Mission 10:
16: mission 10
Mission 11: Download plans for platform
17: first half
35: no idea, looks mostly complete level
37: incomplete cipher chip level
31: incomplete mission 6
20: Looks like part of a mission but incomplete. Has that big computer thing
Mission 12:
19: ????? Third done mission. Start matches.
Mission 13:
21: mission 13
22: incomplete mission 13
39: mission 13 incomplete
40: mission 13 very incomplete
Mission 14:
23: mission 14
24: mission 14 with cheese fart
Mission 15:
25: mission 15

View file

@ -0,0 +1,137 @@
# Bounding Box Feature
## Goal
Add a checkbox to the map renderer web app that shows the white object bounding boxes players could toggle in the original Crusader debug view.
## Research
### ScummVM
Relevant engine path:
- `K:\misc\scummvm\engines\ultima\ultima8\world\item_sorter.cpp`
- `K:\misc\scummvm\engines\ultima\ultima8\world\sort_item.cpp`
Useful findings:
- The Ultima 8 / Crusader sorter keeps explicit screen-space bbox corner values such as `_sxLeft`, `_sxRight`, `_sxTop`, `_sxBot`, `_syTop`, and `_syBot`.
- The debug drawing path is labeled `Draw wire frame footpads` and uses white lines (`0xFF, 0xFF, 0xFF`).
- The wireframe is not just a flat sprite rectangle. It is built from the projected 3D item extents derived from the shape dimensions and the item world position.
Important excerpt of behavior:
- top diamond edges are drawn first
- if the item has height, vertical edges and lower edges are added
- the result is closer to an isometric wireframe box / footpad than a simple 2D selection rectangle
### Pentagram
Relevant engine path:
- `K:\misc\pentagram\world\ItemSorter.cpp`
- `K:\misc\pentagram\world\CurrentMap.cpp`
Useful findings:
- Pentagram uses the same core bbox projection model: world-space `x/y/z` extents become screen-space bbox extents.
- The formulas match the ScummVM sorter closely enough to use them as a second reference for the projection math.
- This confirmed that the current renderer should not invent a brand-new bbox model; it should reuse the original item extent math where possible.
## Implementation
Renderer changes were made in:
- `src/public/index.html`
- `src/public/dom-elements.js`
- `src/public/ui-controls.js`
- `src/public/app.js`
- `src/lib/build-manager.js`
What changed:
1. Added a `Show white bounding boxes` checkbox in the side-panel view toggles.
2. Added an `F` hotkey that toggles bounding boxes on and off unless the current focus is inside an editable text control.
3. Added bbox visibility to the metadata panel so the current view state is explicit.
4. Promoted shape `x/y/z` dimensions from the decoded typeflag info into `shapeDefinitions` inside the scene JSON.
5. Added a client-side bbox overlay renderer that:
- respects the same visibility filters as normal rendering
- skips hidden items
- draws after sprites so boxes remain visible
- uses white wireframe lines based on projected item extents when the scene payload includes dimensions
6. Changed hover and click selection to use the projected bounding-box polygon instead of the sprite image rectangle whenever shape dimensions are available.
7. Replaced the old DOM rectangle highlight with a canvas-drawn bbox highlight:
- when global bounding boxes are disabled, the focused item shows a white bbox highlight by itself
- when global bounding boxes are enabled, the focused item is redrawn in blue above the white global boxes
- hover enter fades in quickly and hover exit fades out more slowly
8. Added a compatibility fallback for older cached scenes: if a scene JSON does not yet contain shape dimensions, the client falls back to rectangle-based selection/highlighting instead of failing.
## Issues Encountered
### Issue 1: Existing scene payload was missing shape extents
Initial problem:
- The viewer already had `item.screen.left/top/right/bottom`, so a quick rectangle overlay was easy.
- That was enough for a first pass, but it did not match the original Crusader-style wireframe closely.
- It also meant hover selection was based on the sprite image rectangle instead of the projected bbox volume.
Resolution:
- The build pipeline already had access to the underlying shape dimensions from `typeflag.dat` decoding.
- Those dimensions were added to `shapeDefinitions`, which let the client reconstruct a closer isometric wireframe and switch hover hit-testing over to bbox geometry without using Ghidra or adding a new server endpoint.
### Issue 2: Cache compatibility
Problem:
- Existing cached scene JSON files and static exports were built before the new `dimensions` field existed.
Resolution:
- The client now detects missing dimensions and falls back to rectangle-based selection/highlighting.
- Rebuilding a map or regenerating the static site will upgrade scenes to the wireframe path automatically.
### Issue 3: Hover highlight behavior changed once boxes stopped being a DOM element
Problem:
- The original inspect highlight was an absolutely positioned DOM rectangle.
- Once the focus outline moved to wireframe bbox geometry, the highlight had to be drawn in the same canvas render path as the scene or it would drift away from the new hit-test model.
Resolution:
- The highlight is now rendered directly on the canvas from the same bbox geometry used by hit-testing.
- This made it straightforward to add the requested quick fade-in and slower fade-out timings.
### Issue 4: Highlight animation state was being reset every render
Problem:
- The inspect/highlight state is recomputed during each render pass.
- The first bbox animation implementation reset the highlight timer every frame, which kept the highlight alpha at zero and made the selected bbox appear to never draw.
Resolution:
- The highlight animation state now resets only when the focused item actually changes.
- This lets hovered inspect targets and hoverable editor objects animate correctly while still being recomputed from live bbox geometry.
### Issue 5: Keep exports unchanged
Problem:
- The bbox checkbox is a debug/viewer affordance, not part of the canonical map export.
Resolution:
- Bounding boxes are drawn only in the interactive viewport path.
- The PNG export path remains unchanged and does not bake the bbox overlay into the exported map image.
## Result
The web app now has a bbox checkbox that behaves like a renderer-side debug overlay and is grounded in the same item extent math used by ScummVM/Pentagram. It does not require Ghidra fallback work for the current implementation.
## Follow-up Notes
- To see the closer wireframe result everywhere, rebuild dynamic caches or rerun the static export so scenes include the new `shapeDefinitions[].dimensions` data.
- If exact parity with the original executable is still needed later, the next step would be verifying the original hotkey/toggle path in the game binary or locating a more explicit Crusader-specific debug toggle in engine code. That was not required for this implementation pass.

View file

@ -0,0 +1,42 @@
# Editor/Helper Object Survey
This pass widened the renderer research beyond egg and NPC spawner objects and focused on editor/helper shapes that already carry useful classification data in the exported scene payload.
## Evidence Base
- The renderer already exports `shapeDefinitions[*]` entries with `kind`, `family`, `dimensions`, `visibilityTags`, `traits`, and the matching catalog entry.
- The public catalogs already distinguish many non-gameplay helper families that are currently easy to miss in the UI.
- Representative catalog anchors in Remorse include:
- `0x005A`, `0x005B`, `0x005C`, `0x005D`, `0x0066`-`0x0069`: invisible/editor wall objects
- `0x01B8`: `camera`
- `0x0290`, `0x0336`: `LIGHT_BRIDGE_*`
- `0x0251`, `0x0318`, `0x0337`, `0x0361`: placeholder cubes and placeholder UI/editor markers
- `0x0108`, `0x0113`, `0x01B9`, `0x01BA`, `0x025F`, `0x0260`, `0x02F0`, `0x0373`, `0x0399`, `0x03A1`, `0x04C8`: `wallgun_shape_*` helper cluster
## What This Means For The Renderer
- A lot of the useful information is already present without more reverse-engineering. The main problem was presentation, not raw data availability.
- Editor/helper objects often carry meaningful `mapNum`, `npcNum`, `quality`, or `nextItem` values even when they are not DTABLE-backed NPC spawners. Those raw linkage values are worth exposing because they help separate placeholder geometry from logic markers.
- Catalog names already identify several broad classes that deserve different handling in the UI:
- invisible walls/editor walls
- camera/helper markers
- light bridge / forcefield / editor-authored bridge surfaces
- placeholder cubes and placeholder UI markers
- auto-derived helper shapes tied to specific USECODE families like `WALLGUN`
## Implemented UI Enrichment
The tooltip now exposes generalized metadata for editor/helper objects instead of reserving extra detail almost entirely for NPC spawners:
- `Dimensions`: shape dimensions from the exported shape definition
- `Tags`: exported visibility tags such as `editor`, `helper`, `egg`, `roof`, and `oob`
- `Traits`: exported rendering/collision traits such as `occluding`, `translucent`, `solid`, `fixed`, and nonzero animation type
- `Role hint`: a cautious catalog-backed note for important helper families like invisible walls, cameras, light bridges, placeholders, `WALLGUN` helper shapes, `0x04D0` NPC spawners, and `0x024F` monster eggs
- `Raw linkage`: `map`, `npc`, `quality`, and `next` fields for editor/helper/egg objects so unresolved objects still expose their control data
## Practical Next Targets
- Add dedicated filters or list views for helper subfamilies such as invisible walls, camera markers, light bridges, and placeholder cubes.
- Add shape-family frequency summaries so repeated helper markers can be audited across a map.
- Decode more shape-specific field semantics for unresolved editor objects like `0x04B1`, `0x0011`, `0x04C9`, `0x04CA`, and `0x04E3`.
- Find the No Regret replacement for the Remorse `0x024F` monster-egg workflow instead of assuming the same shape is reused.

View file

@ -0,0 +1,145 @@
# Egg Identification Investigation
## Goal
Add a reliable egg browser to the map renderer and clarify what the game means by an egg "ID".
## Short Answer
- Fresh-game startup is hardcoded to map `1`, egg `0x1e`.
- That startup egg is not read from `CRUSADER.CFG` or another external mission map.
- For Crusader teleport eggs, the destination number that gameplay matches against is the low byte of the item `quality` field.
- The broader egg families use different payload fields depending on egg type, so the renderer should not assume that every egg-family item uses the same number source.
## Fresh-Game Default Start Egg
The existing reverse-engineering note in [docs/first-mission-map-selection.md](k:/ghidra/Crusader_Decomp/docs/first-mission-map-selection.md) already establishes the startup path:
- normal new game calls `Teleporter_CreateProcessDirect(1, 0x1e, 1)`
- the controlling call site is in `Game_Start`
- this is a code-selected default, not a config-file mapping
That means the known first-map spawn uses teleport egg id `0x1e` on map `1`.
## ScummVM Crusader Egg Model
The ScummVM Crusader engine in `engines/ultima/ultima8` maps Crusader egg families as follows:
- family `3` = `SF_GLOBEGG`
- family `4` = `SF_UNKEGG` (usecode trigger egg)
- family `7` = `SF_MONSTEREGG`
- family `8` = `SF_TELEPORTEGG`
Relevant files:
- [shape_info.h](k:/misc/scummvm/engines/ultima/ultima8/gfx/shape_info.h)
- [item_factory.cpp](k:/misc/scummvm/engines/ultima/ultima8/world/item_factory.cpp)
- [egg.cpp](k:/misc/scummvm/engines/ultima/ultima8/world/egg.cpp)
- [egg.h](k:/misc/scummvm/engines/ultima/ultima8/world/egg.h)
- [monster_egg.h](k:/misc/scummvm/engines/ultima/ultima8/world/monster_egg.h)
- [monster_egg.cpp](k:/misc/scummvm/engines/ultima/ultima8/world/monster_egg.cpp)
- [teleport_egg.h](k:/misc/scummvm/engines/ultima/ultima8/world/teleport_egg.h)
- [teleport_egg.cpp](k:/misc/scummvm/engines/ultima/ultima8/world/teleport_egg.cpp)
- [current_map.cpp](k:/misc/scummvm/engines/ultima/ultima8/world/current_map.cpp)
One structural detail matters here: generic trigger eggs and teleport eggs inherit from `Egg`, but `MonsterEgg` is its own `Item` subclass instead of an `Egg` subclass. That helps explain why Crusader monster eggs do not line up perfectly with the generic egg-field conventions.
## Which Field Holds The Number?
There is not one universal answer for every egg-family item.
### Teleport eggs
Teleport eggs are the important case for spawn/start locations.
ScummVM uses:
- `TeleportEgg::getTeleportId()` = `(_quality & 0xFF)`
- `MainActor::teleport(mapNum, teleport_id)` to move to a destination egg
- `CurrentMap::findDestination(id)` to find a teleport egg target with matching low-byte quality
It also distinguishes two teleport egg roles:
- `frame != 1` = active teleporter trigger
- `frame == 1` = destination marker
So for renderer purposes, the gameplay-relevant teleport egg id is the low byte of `quality`, not `mapNum`.
### Generic/usecode eggs
ScummVM's generic egg intrinsics expose:
- `Egg::I_getEggId()` -> `getMapNum()`
So family `4` eggs use `mapNum` as their generic egg id in the engine interface.
### Monster eggs
ScummVM's monster egg accessor exposes:
- `MonsterEgg::I_getMonId()` -> `getMapNum() >> 3`
That is a separate meaning from teleport ids.
There is an important Crusader-specific wrinkle for renderer work:
- the generic ScummVM `MonsterEgg` model stores monster shape in `quality & 0x7FF` and activity in the low three bits of `mapNum`
- but exported No Remorse `0x024F` frame `0` monster eggs often have `quality = 0` and still carry non-zero `npcNum` values that line up with useful DTABLE rows
- concrete exported examples include Remorse map 69 (`npcNum = 2` and `npcNum = 6`) and Remorse map 2 (`rawNpcNum = 5`)
- No Remorse frame `1` `0x024F` entries also exist, but the checked cases were zeroed placeholders with no useful `npcNum`, `mapNum`, or `quality` payload
- exported No Regret `0x024F` currently diverges earlier: its shape definition resolves to `family: 0`, `kind: terrain` rather than `family: 7`, `kind: egg`, so the renderer should not assume that Remorse monster-egg semantics carry over unchanged
So the renderer keeps two interpretations side by side for family `7` eggs:
- the egg-family label remains `monster id = mapNum >> 3`, matching the engine intrinsic surface
- the NPC preview for evidence-backed `0x024F` frame `0` eggs comes from `npcNum`, because that field matches observed spawn identity better than the zero-heavy `quality` field in current Remorse exports
### Glob eggs
Glob eggs expand glob contents using the item `quality` value as the glob reference.
## Renderer Decision
The new map-renderer egg UI treats all egg-family items as eggs, but labels each one with the field that matches its ScummVM family behavior:
- family `8`: teleport id = `quality & 0xFF`
- family `4`: egg id = `mapNum`
- family `7`: monster id = `mapNum >> 3`
- family `3`: glob id = `quality`
That keeps the viewer useful for teleport/start-point work without flattening all egg families into one misleading scheme.
For the NPC preview overlay, the renderer now makes one additional evidence-backed exception:
- `0x024F` frame `0` monster eggs with a non-zero `npcNum` get the same DTABLE-backed blue NPC preview as `0x04D0` spawners
That exception is intentionally narrower than the generic egg browser labels. It reflects the currently verified Remorse data, not a blanket claim that every family `7` egg in every game uses `npcNum` as its authoritative spawn row.
## Current Cross-Game State Of `0x024F`
- No Remorse: exported shape definition resolves to `family: 7`, `kind: egg`, and scene items produce `egg.type = monster-spawn`
- No Regret: exported shape definition currently resolves to `family: 0`, `kind: terrain`, and scene items do not produce egg metadata
That is the strongest current reason to keep the new monster-egg preview support scoped to the evidence-backed Remorse path instead of enabling it globally for both games.
## Weapon Room `250`
The current renderer catalog data already contains egg-oriented notes that include `250` in the known egg-id lists for Crusader egg shapes, which is consistent with the user-observed convention that egg `250` is the weapons room/test room marker.
Those catalog references are evidence for the workflow and UI, but the renderer should still treat them as conventions layered on top of the raw egg-family decoding above.
## Outcome For The Feature
The egg browser added to the renderer now:
- lists every egg-family item in the loaded map
- shows the decoded id appropriate to that egg family
- can center the camera on a chosen egg and pin-select it
- can draw zoom-stable id labels over eggs in the viewport
## Remaining Uncertainty
Two things are still worth keeping in mind:
- family `7` Crusader handling is still marked in ScummVM as partly suspicious because those records can also behave container-like
- `250 = weapons room` is a strong workflow convention, but this investigation did not add a new executable-side proof beyond the catalog evidence and known reverse-engineering notes

View file

@ -0,0 +1,123 @@
# Map Editing Workflow
## Current Scope
The browser renderer now supports these in-memory map edits:
- add a new teleporter egg to the currently loaded map
- add a new teleport destination egg to the currently loaded map
- edit the teleport id for existing or newly placed teleporter and teleport destination eggs
This works in both dynamic and static site modes because the edit only changes the client-side map source held in memory. Nothing is written back to the original Crusader data files from the browser.
## Browser Behavior
### Add Teleporter Or Destination Egg
- Open the `Eggs` panel.
- The `New teleport ID` field is prefilled with the next free teleport id the renderer can find.
- Change it if you want a different id.
- Click `Add Teleporter` or `Add Destination`.
- Move the cursor over the map. A preview egg follows the pointer.
- Click to place the egg.
- Press `Esc` or click the active button again to cancel placement.
Placement rules:
- if the cursor is over empty space, the new egg is placed at `Z = 0`
- if the cursor is over a rendered item, the new egg uses the top height of that item: `item.world.z + shape_definition.dimensions.z * 8`
- the world position is reconstructed from the current screen point and chosen `Z` using the same isometric projection the renderer already uses for scene items
The current implementation uses the scene's default teleport-egg template shape plus the teleporter or destination frame from the Crusader data set.
If the chosen teleport id is already used by another teleporter or destination egg, the UI warns but still allows the placement.
After a successful placement, the field advances to the next free id again.
### Edit Teleport IDs
- Pin a teleporter or teleport destination egg.
- Click the pen button in the tooltip header.
- Use the modal editor that opens.
- Change the `Teleport ID` and save.
The duplicate-id check is warning-only. The edit is still applied so you can intentionally create or inspect collisions.
For teleport destination eggs, this edits the destination egg's own id.
These edits do not require admin mode because they only change the in-memory FIXED map payload used by the renderer and exports.
### Downloads
The `Downloads` panel now includes:
- `Download Map Binary`: exports the current map as a raw per-map FIXED payload (`16` bytes per item record)
- `Download Map JSON`: exports the current scene JSON, including the editable `mapSource` block
The binary export is the direct payload for a single map entry inside `FIXED.DAT`, not a full rebuilt archive.
## Lossless Map Source
Each built scene now carries a `mapSource` block. That block is the source of truth for editing/export and contains:
- the game id and map id
- the original per-map byte length
- the fixed record size (`16`)
- the default teleport egg template shape/frame
- the full list of FIXED item records decoded into JSON fields
This exists because the painted scene is not a lossless representation of `FIXED.DAT`: invalid or fully occluded records can be dropped during scene generation.
## Offline Split/Rebuild Tool
Use the new npm entry point:
```text
npm run map-compiler -- split --game=remorse
npm run map-compiler -- rebuild --game=remorse --from=json
npm run map-compiler -- rebuild --game=remorse --from=binary
```
If no parameters are supplied, the tool enters an interactive console mode.
### Split Output
Default split output:
```text
generated/map-compiler/<game>/split/
```
Each map is written to its own folder:
```text
map-<id>/map.bin
map-<id>/map.json
```
`map.bin` is the raw per-map FIXED payload. `map.json` is the decoded JSON form using the same item structure as the browser `mapSource`.
If you changed teleport ids or placed new teleport-related eggs in the browser before exporting, those in-memory changes are reflected in the exported JSON and map binary output.
### Rebuild Output
Default rebuild output:
```text
generated/map-compiler/<game>/rebuilt/FIXED.DAT
```
The tool always writes rebuilt archives into a generated subfolder and refuses to overwrite the original `FIXED.DAT`.
Rebuild modes:
- `--from=json`: rebuild each map payload from `map.json`
- `--from=binary`: rebuild each map payload from `map.bin`
Maps without a replacement file keep their original payload from the source archive.
## Notes
- The browser does not currently write changes back into the source static asset tree.
- The browser edit path currently only creates teleport eggs.
- The archive rebuild preserves the original non-map header region and rewrites the map table/payload layout into a generated output directory.

View file

@ -0,0 +1,216 @@
# NPC Spawner Investigation
## Goal
Recover the meaning of the `npcNum` field on Crusader editor and egg-side helper objects, determine which shapes really use DTABLE-backed NPC records, and surface only the evidence-backed lookup data in the public map renderer.
## Short Answer
- Shape `0x04D0` editor objects are not backed by a hardcoded executable-only NPC name list.
- For `0x04D0`, the `npcNum` field indexes external NPC metadata stored in `STATIC/DTABLE.FLX` for No Remorse and `STATIC_REGRET/DTABLE.FLX` for No Regret.
- The relevant DTABLE layout matches ScummVM's Crusader `NPCDat` loader:
- object `0` = fixed `142`-byte NPC rows
- object `2` = parallel `32`-byte NUL-terminated names
- Current evidence does not justify applying that same DTABLE lookup to every shape that happens to carry an `npcNum` byte.
- The renderer now exposes the recovered DTABLE name and spawn shape only for the evidence-backed `0x04D0` path.
## Verified File Layout
ScummVM's Crusader engine already documents the DTABLE structure in `world/actors/npc_dat.cpp`:
- row size = `142` bytes
- name size = `32` bytes
- shape offset = `0x3e`
- min/max HP = `0x00/0x02`
- default weapon slots = `0x18/0x1a`
- default activities = `0x1e`, `0x40`, `0x42`
Its `filesys/flex_file.cpp` also confirms the Flex header shape used to read the archive index:
- entry count at file offset `0x54`
- object table at file offset `0x80`
Those offsets were used to extract the local retail DTABLE rows directly.
## Executable-Side Corroboration
The live `CRUSADER.EXE` session already had the core DTABLE helper names recovered:
- `1118:0075` = `DTable_Load`
- `1118:056a` = `DTable_GetNameForShapeNo`
- `10e8:0152` = `NPC_SetDataFromDTable`
- `10e8:336d` = `NPC_DTable_GetMaxHPForNPC`
That function cluster is consistent with `npcNum` being an index into external DTABLE-backed NPC records, not a local `0x04D0`-specific switch table.
## Shape Review
This pass specifically re-checked the shapes that came up during renderer work: `0x04D0`, `0x024F`, `0x04B1`, and `0x0011`.
### `0x04D0`
- The old Crusader disassembly corpus labels usecode class `0x04D0` as `MONSTER`.
- Existing local analysis in `docs/usecode-alarmhat-analysis.md` shows multiple scripts scanning nearby shape `0x04D0` objects and driving them through monster/helper behavior.
- In the current script evidence, those nearby scans explicitly require frame `0` on the found `0x04D0` object before calling the equip/activation helper.
- The executable-side DTABLE helper cluster and the extracted DTABLE rows line up cleanly with the renderer's `npcNum` observations for this shape.
Current conclusion: `0x04D0` is the confirmed DTABLE-backed NPC spawner/controller shape, so DTABLE name lookup is appropriate here. Frame `0` is the better-supported actionable/helper state. Frame `1` still appears as a paired variant in map data, but this pass does not yet pin down its exact semantics.
### `0x024F`
- In No Remorse, the current renderer and ScummVM family mapping both classify `0x024F` as a family `7` monster egg shape.
- Exported Remorse scene data shows frame `0` `0x024F` eggs with non-zero `npcNum` values and family `7` egg metadata. Example cases include Remorse map 69 with `npcNum = 2` and `npcNum = 6`, and Remorse map 2 with `rawNpcNum = 5` on another `0x024F` monster egg.
- The preview path now treats those frame `0` monster eggs as a second evidence-backed DTABLE consumer. After rebuilding cache, Remorse map 69 produces a concrete preview case where a `0x024F` frame `0` egg with `npcNum = 6` resolves to `Observer` and emits `npcPreview.shapeDefId = shape:828`.
- Frame `1` `0x024F` records also appear in No Remorse, but the concrete checked cases were zeroed placeholders with `mapNum = 0`, `npcNum = 0`, `quality = 0`, and no preview metadata. The current renderer therefore only enables the preview for frame `0` monster eggs with a non-zero `npcNum`.
- In current exported No Regret data, `0x024F` is not only visually categorized differently. The exported shape definition itself resolves to `family: 0`, `kind: terrain`, unlike the No Remorse definition which resolves to `family: 7`, `kind: egg`. That means the present Regret difference is coming from the underlying typeflag/family data, not from a UI-only catalog mismatch.
Current conclusion: `0x024F` frame `0` monster eggs are not the same authoring form as `0x04D0`, but in Remorse they do carry usable DTABLE-style `npcNum` rows. That is strong enough to preview the spawned NPC for those eggs, while keeping the broader egg-family interpretation separate from the `0x04D0` editor/controller path.
### `0x04D0` Field Ambiguity Notes
- The strongest current activation control lives in `MONSTER.slot_0F enterFastArea`, not in DTABLE. That script only checks `0x04D0` objects when `frame == 0`, then blocks the automatic enter-area lane if `mapNum & 0x08` is set.
- Current safest read: `frame 0` is the only state that participates in the automatic `enterFastArea` spawn path, while `frame 1` skips that hook and is therefore more likely to be used in paired or externally signaled setups.
- `mapNum` is not consistently populated across `0x04D0` objects. Some records carry a non-zero map value and others leave it at `0`, so the field is probably contextual control data rather than a universal NPC selector.
- Within that contextual control data, bit `0x08` is now evidence-backed as an `auto-enter disabled` flag for the `MONSTER.enterFastArea` lane.
- `quality` is also not specific to the DTABLE row. For example, `quality = 1285` shows up on unrelated non-`0x04D0` shapes in the exported scenes, so that value should not be read as proof of a particular NPC identity.
- `quality` low byte still does not look like the primary `spawn immediately vs wait` control. The current exported scripts do not use it in `MONSTER.enterFastArea`, although Regret `ALARMHAT` does compare nearby `0x04D0` `Item.getQLo(...)` values against difficulty lanes `0/1/2` before equipping those helpers.
- DTABLE row `0` is named `Crusader`, but the open-source engine does not use DTABLE row `0` to bootstrap the player. ScummVM's `CruGame::startGame()` takes the main actor stats from `getNPCDataForShape(1)`, while the generic Crusader actor-creation path accepts `npcNum = 0` as an ordinary DTABLE index.
- Exported scene data shows a repeatable authored pattern where a `0x04D0` frame `0` record with `npcNum = 0` is colocated with a frame `1` record carrying the non-zero NPC row. Example pairs include exported Remorse map 246 and map 9, where the frame `0` record stays at `npcNum = 0` while the frame `1` partner carries rows `8` and `2` respectively.
- Current working model: `npcNum = 0` on `0x04D0` is a real authored state, but not strong evidence that the object literally means "spawn the player". It is safer to present it as DTABLE row `0` with a caution note than to relabel it as a special player-only sentinel.
### `0x04B1`
- `0x04B1` is catalogued as an editor object in both games.
- In the recovered usecode exports, `TRIGGER.slot_20` iterates nearby `shape=0x04B1` items, compares `Item.getQLo(item)` against a base-link id, then branches on `Item.getMapNum(item)` flag bits before dispatching more trigger logic.
- That makes `0x04B1` look like a trigger/link controller object rather than a plain NPC-row selector.
Current conclusion: `0x04B1` does use the standard item fields, including `mapNum` and low-quality link data, but this pass did not find evidence that its `npcNum` byte should be decoded through DTABLE/NPCDat. The renderer should keep showing the raw fields, not an inferred NPC name.
### `0x0011`
- `0x0011` is catalogued as an egg-side editor trigger shape in the public renderer catalogs.
- The usecode export corpus references `0x0011` in several trigger-oriented constant sets, but this pass did not recover a clean DTABLE/NPCDat correlation for that shape.
- The current evidence is therefore weaker than for `0x04D0` and does not support treating `0x0011` as just another DTABLE NPC spawner.
Current conclusion: `0x0011` should continue to display its raw `npcNum` field for inspection, but not receive automatic DTABLE name resolution unless later RE work proves the mapping.
## Recovered No Remorse Examples
Direct extraction from `STATIC/DTABLE.FLX` produced these representative rows:
| npcNum | Name | Shape |
| --- | --- | --- |
| 4 | `GUARD` | `0x02fd` |
| 6 | `Observer` | `0x033c` |
| 10 | `Thermatron` | `0x0338` |
| 15 | `EliteStormTrooper` | `0x04d1` |
| 19 | `Vetron` | `0x04e6` |
| 33 | `FemaleOfficeWorker` | `0x0597` |
The original user hunch was effectively correct: No Remorse entry `6` is an otherwise obscure NPC that uses shape `0x033c`. The recovered DTABLE name for that row is `Observer`.
## Recovered No Regret Differences
No Regret keeps the same storage scheme but changes several rows:
| npcNum | Name | Shape |
| --- | --- | --- |
| 4 | `LMC Guard` | `0x0308` |
| 6 | `HQ Guards` | `0x057a` |
| 14 | `LMC Roll Out` | `0x05f0` |
| 30 | `Colonel Shepherd` | `0x0463` |
| 35 | `RoboDraygon` | `0x05b1` |
| 38 | `Cryotron` | `0x05e2` |
Because the public renderer supports both games, the UI lookup is keyed by game id rather than assuming a single shared NPC table.
## Relationship To Monster Eggs
These `0x04D0` NPC spawners are related to monster eggs only in the broad sense that both can result in actor creation.
They are not the same data path.
- The existing egg investigation showed that Crusader monster eggs use the egg-family path where the monster id is derived from `mapNum >> 3`.
- The `0x04D0` spawner path instead uses the item's `npcNum` byte as a DTABLE/NPCDat record index.
- Remorse `0x024F` frame `0` monster eggs appear to bridge those two worlds: they are still family `7` eggs, but exported scene data shows that they can also carry a non-zero `npcNum` that matches a useful DTABLE row for preview and inspection.
- That means a renderer should not label `0x04D0` editor objects as monster eggs or try to decode their `npcNum` through egg-family rules.
- `0x0011` remains in the broader egg/editor lane, but this pass did not find evidence that its `npcNum` field reuses the `0x04D0` DTABLE path.
Current safest model:
- monster eggs = egg-family object with monster id packed through the egg fields
- `0x024F` frame `0` monster eggs in Remorse = family `7` eggs that also expose a useful DTABLE-backed `npcNum` for preview
- `0x04D0` NPC spawners = editor objects pointing into DTABLE/NPCDat rows
Current practical difference between the two monster-spawn styles:
- `0x04D0` is the more explicit editor/controller spawner path. It carries larger visible editor-object state, shows paired frame behavior, and matches the recovered DTABLE helper cluster directly.
- `0x024F` is the egg-family monster spawn path. In Remorse frame `0`, it still exposes enough `npcNum` information to recover the spawned NPC for renderer preview, but it remains an egg-trigger object rather than the broader `0x04D0` controller form.
## Renderer Change
The tooltip now resolves recovered DTABLE metadata by game id and shows, for confirmed DTABLE-backed shapes:
- raw `npcNum`
- recovered DTABLE name when known
- DTABLE spawn shape when known
- a short frame note for `0x04D0` items so frame `0` vs `1` stays visible during inspection
- a decoded activation summary for `0x04D0` showing the current best read of `frame` plus `mapNum & 0x08`
For editable fixed-record `0x04D0` items, the inspector now also exposes two evidence-backed export controls:
- `Spawner frame`: switches between the `frame 0` enter-area checked state and the `frame 1` skip-enter-area state
- `Enter-area lane`: clears or sets `mapNum` bit `0x08`, which is the verified automatic-spawn suppression bit used by `MONSTER.enterFastArea`
The side panel now also exposes a dedicated `Monster Spawners` audit list for `0x04D0` records, including a filter for the `auto-enter blocked` subset. Clicking an entry centers and pins that spawner so its raw fields and editable controls can be audited quickly.
The viewport's `Show verified link arrows` overlay now draws two evidence-backed link families:
- teleport eggs point from teleporter eggs to teleport destinations that share the same teleport ID
- focused `0x04D0` items draw dashed arrows to nearby opposite-frame `0x04D0` candidates that share the same low-quality link key, reflecting the current paired-spawner hypothesis without claiming a stronger global linkage than the usecode proves
Current script-side corroboration for external `0x04D0` signaling is broader than `ALARMHAT` alone. The extracted public pseudocode also shows `ITEM.slot_2D`, `FUSPAC.slot_01 use`, and `MISS8.slot_20` scanning nearby `0x04D0` objects and keying off frame plus `Item.getQLo(...)`, which is why the renderer treats the low-quality byte as a local signal key rather than a guaranteed direct object pointer.
The lookup is no longer intended to be hand-maintained. `src/generate-npc-spawner-data.js` now extracts both games' rows from `STATIC/DTABLE.FLX` and `STATIC_REGRET/DTABLE.FLX` and writes the generated JSON file into the renderer cache at `.cache/npc-spawner-data.generated.json`.
The frontend consumes that JSON through `src/public/npc-spawner-data.js`, so the runtime data file is plain generated content rather than JavaScript codegen.
When a valid DTABLE row is present, the viewport renders a semitransparent blue preview of the target NPC shape above every visible eligible spawner whenever editor objects are visible. Hovered or pinned spawners are still drawn slightly stronger, but preview ghosts are no longer hover-only. The current renderer enables this for:
- `0x04D0` DTABLE-backed editor spawners
- `0x024F` frame `0` Remorse monster eggs that carry a non-zero `npcNum`
The current renderer uses frame `0` for NPC previews by default, except for `Observer`, which is forced to frame `0x00F` because the earlier frames are blank/broken in the retail assets.
Representative exported scene pairs:
- Remorse map 246: frame `0` item `item:162` uses `npcNum = 0`, `mapNum = 8`, `quality = 256`, while colocated frame `1` item `item:163` uses `npcNum = 8` with the same quality.
- Remorse map 9: frame `0` item `item:338` uses `npcNum = 0`, `mapNum = 0`, `quality = 1829`, while colocated frame `1` item `item:339` uses `npcNum = 2`.
- The same frame-paired authoring pattern also appears in Regret exports, although the exact non-zero partner row differs by map.
Serving paths now match the renderer modes:
- dynamic mode serves the cached JSON through `/api/npc-spawner-data`
- static export copies the cached JSON to `site/data/npc-spawner-data.json` and points `site-config.json` at that exported file
Current regeneration paths:
- `npm run generate-npc-data`
- `npm run build-cache`
- `npm run export-static`
Examples:
- No Remorse `npcNum 6` now displays as `6 (Observer)` with NPC shape `0x033c`
- No Regret `npcNum 35` resolves to `RoboDraygon` with NPC shape `0x05b1`
## Remaining Uncertainty
- This note closes where the `0x04D0` NPC list lives and how to recover names, but it does not yet prove every gameplay path that can instantiate those DTABLE rows.
- `0x04B1` has strong trigger/link evidence but still needs a fuller write-up tying its item fields to specific map-editing behavior.
- `0x0011` is still only partially characterized here; it is clearly in the egg/editor lane, but its relationship to `npcNum` remains unresolved.
- Shape `0x04D0` still uses the `MONSTER` usecode class in the old disassembly corpus, so there is still room to document how that script/controller layer cooperates with the DTABLE-backed actor creation path.
## TODO
- Do a deeper pass on `0x04B1` and `0x0011` so the raw `npcNum` field can be documented more precisely without overfitting DTABLE assumptions.

View file

@ -0,0 +1,281 @@
# Vue Refactor Plan
## Purpose
Refactor the map renderer from a large vanilla JavaScript application into a Vue application while preserving its two deployment modes:
- Static mode for GitHub Pages and other read-only deployments
- Dynamic local mode for catalog editing and local data updates
The refactor should reduce the size and complexity of the current `src/public/app.js` entrypoint without breaking the existing viewer behavior, static export workflow, or catalog editing workflow.
## Current Baseline
The renderer already has a useful split between source files and deployment artifacts:
- `src/public/app.js` currently orchestrates most of the browser app.
- `src/public/map-catalog-ui.js`, `src/lib/catalog.js`, and related modules already isolate some catalog logic.
- `site/` contains the committed static bundle used for GitHub Pages.
- `package.json` already exposes separate static and dynamic scripts.
- The root README already documents static versus dynamic usage.
That means the migration should be an incremental extraction, not a from-scratch rewrite.
## Non-Negotiable Requirements
1. Keep a static deployable build that can run from committed artifacts with no file-system write capability.
2. Keep a dynamic local build that can edit the Catalog and write changes back to disk.
3. Preserve the existing viewer workflow during migration so the app remains usable at every step.
4. Keep catalog editing opt-in and unavailable in static deployments.
5. Avoid coupling the UI framework change to data format changes unless a boundary must move for architectural reasons.
## Target Architecture
The Vue refactor should split the app into four layers:
1. App shell and routing-like mode selection
- Owns top-level mode detection: static versus dynamic.
- Chooses the correct feature set, status messages, and edit affordances.
- Remains thin and delegates real work to feature components and composables.
2. View components
- Replace the current procedural DOM wiring with Vue components for the map controls, viewport, catalog panel, tooltips, modal editor, and export actions.
- Keep components small and purpose-specific.
- Prefer composition over a single mega component.
3. Composables and state services
- Move map selection, build triggers, UI flags, and interaction state into composables or store-like modules.
- Keep business rules out of templates.
- Preserve existing helper modules where they already work.
4. Data access adapters
- Introduce a strict boundary between read-only data access and writable catalog access.
- Static mode uses read-only adapters only.
- Dynamic mode uses writable adapters for catalog updates and local server APIs.
## Mode Model
The app should continue to behave as one codebase with two runtime modes.
### Static Mode
- Serves prebuilt assets from `site/` or equivalent deployed output.
- Allows viewing, filtering, inspecting, and downloading data.
- Does not expose any actions that would write to disk or call write-enabled server endpoints.
- Can still show the Catalog UI if the data is available, but all editing controls must be removed or disabled.
### Dynamic Mode
- Runs against the local Node server.
- Can edit Catalog data through the UI.
- Can write CSV updates to disk.
- Can refresh or rebuild derived data as needed after edits.
- Must preserve the same viewer experience as static mode wherever the data model overlaps.
## Proposed Migration Phases
### Phase 0: Freeze the boundary
Goal: identify the stable interfaces before moving UI code.
- Inventory the current `app.js` responsibilities.
- Group code into categories: rendering, state mutation, map loading, catalog updates, overlays, modals, downloads, and notifications.
- Identify the minimum data contracts between the viewer, catalog, and scene APIs.
- Decide which existing helper modules stay unchanged and which become composables or services.
Deliverable:
- A written boundary map for the current app and a component breakdown for Vue.
### Phase 1: Scaffold Vue without changing behavior
Goal: get Vue running while preserving the current app.
- Create a Vue 3 application shell.
- Move the current page layout into Vue root components.
- Keep the existing data modules and API helpers in place.
- Mount the viewer in Vue but continue using the current scene and catalog logic.
- Add a compatibility layer so existing DOM-driven utilities can be replaced one section at a time.
Deliverable:
- The app loads through Vue, but functionality remains equivalent to the current version.
### Phase 2: Split the UI into feature components
Goal: remove the largest chunks of imperative DOM code.
- Break the viewer into components such as header/control bar, map selector, viewport, catalog panel, export area, status area, and editor modal.
- Move local UI state into Vue reactive state or composables.
- Replace direct DOM updates with props, emits, and computed state.
- Keep the map rendering and scene logic isolated so the UI migration does not change map semantics.
Deliverable:
- The main app flow no longer depends on a monolithic procedural entrypoint.
### Phase 3: Formalize static and dynamic adapters
Goal: separate read-only rendering from writable editing behavior.
- Introduce a read-only adapter for static deploys.
- Introduce a writable adapter for the local Catalog editor.
- Make the app choose adapters based on mode, not scattered conditionals.
- Ensure static mode cannot accidentally call writable endpoints.
- Ensure dynamic mode can still perform all current editor actions.
Deliverable:
- The same Vue UI can run in both modes without leaking write capability into static builds.
### Phase 4: Migrate Catalog editing carefully
Goal: preserve catalog edit power while moving to Vue.
- Port the current catalog editor interactions into Vue components.
- Keep edit forms, validation, and confirmation behavior intact.
- Preserve undo or session history behavior if it exists in the current app.
- Verify that write operations remain local-only and mode-gated.
- Confirm that static builds either hide the edit UI or render it inert.
Deliverable:
- Catalog editing works in dynamic mode and is unreachable in static mode.
### Phase 5: Clean up the old entrypoint
Goal: remove dead code and simplify maintenance.
- Delete or shrink the legacy procedural bootstrap code only after feature parity is reached.
- Move any remaining helpers into reusable modules or composables.
- Remove duplicate state management paths.
- Keep the public API surface stable where external scripts or tests depend on it.
Deliverable:
- Vue owns the app; the old entrypoint is no longer the main coordination layer.
## Suggested Component Breakdown
The exact component names can change, but the refactor should likely include:
- AppShell: top-level mode detection, configuration, and page layout.
- MapSelector: map and game selection, navigation, and load triggers.
- ViewerViewport: canvas or scene host, pointer handling, and zoom state.
- EditorToolbar: view toggles, overlays, and map display controls.
- CatalogPanel: catalog browsing, download buttons, and edit affordances.
- CatalogEditModal: edit form and validation.
- StatusBar: loading, build, and error feedback.
- TooltipOverlay: pinned or hover inspection UI.
- DownloadActions: export buttons and static bundle downloads.
## Data and State Boundaries
The refactor should make these boundaries explicit:
- Scene loading state: selected map, build progress, current assets, and error state.
- View state: zoom, pan, overlays, tooltip pinning, and selection.
- Catalog state: active game, catalog entries, edit permissions, and pending writes.
- Environment state: static versus dynamic mode, feature flags, and API base URLs.
Rule of thumb:
- If state is shared across multiple UI regions, it belongs in a composable or store.
- If state only matters inside one panel or modal, keep it local to that component.
## Static Build Plan
The static build must remain a first-class deployment target.
- Keep the committed site output deployable without server writes.
- Make the Vue build emit a static artifact suitable for GitHub Pages.
- Ensure all catalog editing UI is absent or disabled in the static bundle.
- Keep download links and read-only inspection available.
- Preserve the existing export workflow so the static bundle can be regenerated from local source assets.
## Dynamic Build Plan
The dynamic build must preserve local editing capability.
- Keep the local server as the source of truth for writable catalog changes.
- Preserve file write paths and validation rules.
- Continue to support local refresh or rebuild behavior after edits.
- Keep the editing workflow explicit so the writable mode cannot be mistaken for the static deploy.
## Suggested Repo Changes
The migration will probably require these changes:
- Introduce a Vue source directory under `map_renderer/src` or a sibling frontend directory.
- Add a Vue build pipeline and update package scripts.
- Split the current DOM utility modules into composables, services, and small components.
- Add a mode/config module that clearly states whether the app is static or dynamic.
- Update export scripts so the static site still writes the correct committed artifacts.
- Update the README with the new build and deployment flow once the migration is stable.
## Risks
- The biggest risk is mixing UI migration with behavior changes and losing parity.
- The second risk is accidentally allowing edit controls into the static bundle.
- The third risk is over-centralizing state in one large Vue store and recreating the same maintainability problem in a new framework.
- The fourth risk is changing the export pipeline before the static and dynamic modes are fully separated.
## Acceptance Criteria
The refactor is complete when all of the following are true:
1. The app runs as a Vue application.
2. The static bundle still deploys and remains read-only.
3. The dynamic local build still supports Catalog editing and writes to disk.
4. The main viewer flows match the current behavior closely enough that no feature regression is visible in normal use.
5. The old procedural entrypoint is no longer the primary app coordinator.
6. The codebase is easier to extend because UI, state, and data access are separated.
## Recommended Order of Work
1. Inventory the current app responsibilities and define component boundaries.
2. Scaffold Vue and mount the existing app behind it.
3. Extract the largest UI regions into Vue components.
4. Introduce explicit static and dynamic data adapters.
5. Port catalog editing into Vue while keeping write capability local-only.
6. Remove the leftover procedural bootstrap code.
7. Update the docs and deployment instructions.
## Concrete Implementation Checklist
### Phase 1 Checklist
1. Add Vue 3 and Vite dependencies to the renderer package.
2. Add Vite scripts for development and production builds without removing the existing Node server scripts.
3. Create a new Vue entrypoint that mounts a root app shell into the current page layout.
4. Keep the existing static HTML usable during the transition by preserving the current shell structure and mount target.
5. Split the top-level UI into a Vue root component plus a small compatibility layer for existing state and API helpers.
6. Keep read-only viewer behavior intact during the initial scaffold so the app still loads maps exactly as before.
7. Confirm that static mode still hides all write-capable catalog controls.
8. Confirm that dynamic mode still exposes catalog-edit affordances after the Vue shell is in place.
### Phase 1 Exit Criteria
1. The renderer can be built and served through Vue/Vite.
2. The current viewer still loads and renders maps.
3. The static deployment path remains read-only.
4. The dynamic deployment path remains capable of catalog editing.
5. The old entrypoint is no longer the only place that owns page-level structure.
### Phase 1 Progress
- Vue 3 and Vite scripts have been added to the renderer package.
- A Vue shell now renders the existing viewer DOM structure.
- The Vue shell loads the legacy browser app after mount so the current viewer logic remains active.
- The Vue production build completes successfully.
- The local dev and admin launchers now wait for the Vue build output and then start the server on the Vue-preferred path.
### Phase 2 Progress
- The Vue shell has been split into separate shell components for the side panel, viewport, and egg editor modal.
- The root Vue app now composes those feature components instead of keeping the entire shell in one template file.
- The default local runtime path now serves the Vue build when it exists, so the componentized shell is the main entry for dev and admin modes.
## Final Notes
The best outcome here is not a perfect one-shot rewrite. It is a controlled migration that keeps the current app working, makes the static and dynamic deployment split explicit, and steadily moves the renderer toward a maintainable Vue architecture.

240
docs/psx/prealpha.md Normal file
View file

@ -0,0 +1,240 @@
# Crusader 2 Pre-Pre Alpha (PlayStation) Recon
## Scope
- Target disc tree: `E:\emu\psx\Crusader 2 Pre-Pre Alpha`
- Active Ghidra program for this pass: `/psx/prealpha/SLUS_002.68`
- Comparison baseline: retail PlayStation `Crusader: No Remorse` findings in `docs/psx/psx.md`
- Goal of this pass: identify concrete differences from the released PSX build and record any interesting early-build leftovers visible in the executable and disc layout.
## Immediate Conclusions
- This pre-alpha does **not** currently look like a fully distinct `Crusader 2` executable identity.
- The executable still carries direct `Crusader: No Remorse` branding and other No Remorse-facing UI text.
- The level-loader code is already very close to the retail PSX loader: it still embeds all seven `\LSETn\L` prefixes and the same threshold split at map indices `10/20/30/40/50/60`.
- The disc content is dramatically reduced compared with retail: only `3` shipped level bundles, `1` XA file, no shipped movie `.STR` files, and no `LICENSEA.DAT` or `ZZZ.ZZZ` root files.
- The executable still retains content/system references that do not match the current unpacked pre-alpha disc, including `\AUDIO\TALK1.XA;1` and a `LoadExec` helper for `MENU.EXE`, `ENGINE.EXE`, and `PSX.EXE`.
- Current safest read: this build looks more like an earlier trimmed branch of the No Remorse PSX code/content pipeline than a clearly rebranded or content-rich standalone `Crusader 2` PSX build.
## Disc Tree Differences From Retail PSX
Top-level inventory contrast from the unpacked trees:
| Area | Retail `Crusader: No Remorse` | Pre-pre alpha | Current read |
|---|---:|---:|---|
| `SLUS_002.68` size | `675,840` bytes | `667,648` bytes | pre-alpha executable is smaller by `8,192` bytes |
| `.WDL` files | `66` | `7` | heavy content reduction |
| `.STR` files | `33` | `0` | no shipped movie streams in the pre-alpha tree |
| `.XA` files | `2` | `1` | only `MULTI8.XA` remains |
| `LSET` folders | `LSET1` through `LSET7` | `LSET1` only | only `L0.WDL`, `L1.WDL`, `L2.WDL` shipped |
| `MOVIES/` contents | populated | empty | folder exists but no `.STR` payloads |
| root extras | `LICENSEA.DAT`, `ZZZ.ZZZ` present | both absent | pre-alpha root is much leaner |
Pre-alpha top-level tree at this point:
- `AUDIO/MULTI8.XA`
- `FMV.BIN`
- `LEGAL.SCR`
- `LSET1/L0.WDL`
- `LSET1/L1.WDL`
- `LSET1/L2.WDL`
- `MENUS/M13.WDL`
- `MENUS/M21.WDL`
- `MENUS/M5.WDL`
- `SLUS_002.68`
- `SPEC_A.WDL`
- `SYSTEM.CNF`
Important contrast with retail:
- retail ships `62` level bundles across `LSET1..LSET7`
- this pre-alpha tree currently ships only `3` level bundles in `LSET1`
- retail ships `33` movie streams; this pre-alpha has an empty `MOVIES/` folder
- retail ships `MULTI8.XA` and `TALK1.XA`; this pre-alpha tree currently exposes only `MULTI8.XA`
## Executable Findings
### 1. Executable identity still says `No Remorse`
The strongest branding strings in `/psx/prealpha/SLUS_002.68` still point at the first game, not a visible `Crusader 2` identity.
Relevant strings:
- `80061d08`: `Crusader: No Remorse (save# `
- `80010944`: `This is no time to show Remorse! ... Are you sure you want to Quit?`
Negative checks from this pass:
- no recovered `Crusader II` string
- no recovered `Regret` string
- no recovered alternate save-title branding
Current safest read:
- at least at the executable/UI-string layer, this pre-alpha still looks branded as a No Remorse derivative rather than a separately named sequel build
### 2. The level loader is already essentially retail-shaped
Function `80038084` was renamed in Ghidra during this pass to `wdl_resource_bundle_load_by_index`.
Why the rename is justified:
- the function loads one of seven hardcoded level-path prefixes based on map-index thresholds
- it appends the decimal map index and the shared extension tail to form the final level path
- it also loads `\SPEC_A.WDL` during the same setup path
Direct evidence:
- `80010de0..80010e30` contains the contiguous prefix table:
- `\LSET1\L`
- `\LSET2\L`
- `\LSET3\L`
- `\LSET4\L`
- `\LSET5\L`
- `\LSET6\L`
- `\LSET7\L`
- `80010e34` contains `\SPEC_A.WDL`
- the decompiled threshold ladder in `wdl_resource_bundle_load_by_index` still splits on `9`, `0x13`, `0x1d`, `0x27`, `0x31`, and `0x3b`, which is the same practical `10/20/30/40/50/60` bucket logic previously closed in the retail PSX note
Important contrast with the disc tree:
- the code still expects the full seven-folder retail-style layout
- the current pre-alpha disc only ships `LSET1/L0..L2`
This is one of the strongest current signs that the executable pipeline was still aligned with a much larger planned content set than the media actually present in this build.
### 3. Mission and passcode scaffolding are still close to retail No Remorse
Recovered strings show that the pre-alpha executable still preserves the same visible mission/passcode UI scaffolding seen in the retail PSX work.
Relevant strings:
- `800101a4..80010340`: `Mission Briefing ^Mission 1` through `Mission Briefing ^Mission 15`
- `80060b70`: `Congratulations! You have completed your mission. The passcode for the next mission is:`
- `80060bd4`: the alternate completion message without the visible passcode line
- `80060b50`: `BCDFGHJKLMNPQRSTVWXZ0123456789`
Current read:
- the same `15` mission-facing text slots are still present
- the same consonant/digit passcode alphabet is still present
- this does **not** look like a radically different mission-shell rewrite
This pushes the current pre-alpha closer to `early/reduced No Remorse PSX branch` than to `already content-diverged sequel shell`.
### 4. Audio and movie state looks incomplete relative to both retail code and the current disc
The pre-alpha tree contains only one XA file:
- `AUDIO/MULTI8.XA`
But function `80045648` still contains the two-path XA switch logic:
- param `0` selects `\AUDIO\MULTI8.XA;1`
- nonzero selects `\AUDIO\TALK1.XA;1`
That function was annotated in Ghidra during this pass because the current unpacked disc does **not** contain `TALK1.XA`.
Interesting consequence:
- the executable still expects a second XA stream family that is absent from the current pre-alpha disc tree
Movie side status is even more reduced:
- `MOVIES/` exists but is empty
- no `.STR` files are present in the pre-alpha tree
- the main executable did **not** yield `FMV` or `MDEC` strings in this pass
- `FMV.BIN` still exists on disk, but the current main executable looks less movie-facing than retail at the string level
Current safest read:
- the audio/movie pipeline was not fully stripped from code expectations, but the shipped pre-alpha disc content is notably incomplete compared with retail
### 5. A split-executable `LoadExec` path still exists
Function `80046aac` was not renamed yet, but it was comment-annotated in Ghidra during this pass.
That helper selects one of three executable paths:
- `cdrom:\ENGINE.EXE;1`
- `cdrom:\MENU.EXE;1`
- `cdrom:\PSX.EXE;1`
Then it performs a PSX `LoadExec(...)` handoff after shutting down active systems.
Important contrast with the current pre-alpha disc tree:
- none of `ENGINE.EXE`, `MENU.EXE`, or `PSX.EXE` exist in the unpacked root
- only `SLUS_002.68` is present as the obvious boot executable
Current safest read:
- this build still preserves a real split-executable chainload helper from an earlier architecture or earlier content-layout plan
- the current unpacked disc no longer matches that design literally
I did **not** yet promote a stronger functional name for `80046aac` because the surviving call sites were not fully classified in this pass.
### 6. Content-name tables remain close to retail rather than showing obvious sequel-only replacements
The nearby item/ammo/gun tables are broadly familiar from the retail PSX note.
Recovered examples:
- ammo table still includes `JL-2 AMMO`, `AR-7 AMMO`, `GL-303 AMMO`, `RP-22 AMMO`, `SG-A1 AMMO`
- item table still includes `INHIBITOR`, `CREDITS`, `SCI PLANS`, `BLAST PAC`, `DET PAC`, `DATA LINK`, `LAND MINE`, `SPIDER BOMB`, `MEDICAL KIT`, `ENERGY CUBE`, `FUSION PAC`, `CHEMICAL BATTERY`, `FISSION BATTERY`, `FUSION BATTERY`, `GRAVITON GENERATOR`, `IONIC GENERATOR`, `PLASMA GENERATOR`
- gun table still includes `RP-16`, `RP-22`, `RP-32`, `SG-A1`, `AC-88`, `PA-31`, `EM-4`, `PL-1`, `UV-9`, `GL-303`, `AR-7`, `JL-2`, `JL-9`
Current read:
- the visible catalog tables are still basically in the retail No Remorse family
- this pass did **not** surface any obviously new `Crusader 2`-specific naming layer in the executable text
### 7. Developer-facing PSYQ graphics/debug strings are still present
This is not necessarily unique to the pre-alpha, but it is worth preserving because it may help later graphics-focused passes.
Recovered strings include:
- `ResetGraph(%d)...`
- `SetGraphReverse(%d)...`
- `SetGraphDebug:level:%d,type:%d reverse:%d`
- `LoadImage`
- `StoreImage`
- `MoveImage`
The recovered function `SetGraphDebug` simply stores the requested debug level and prints the diagnostic line when nonzero.
Current practical value:
- if later PSX rendering work needs debug/graphics control anchors, these strings and helpers are already easy to relocate in the pre-alpha database
## Interesting Build-Level Implications
The combined disc-plus-executable picture currently supports a fairly specific interpretation.
Most likely current model:
1. the pre-alpha executable is still largely a No Remorse-derived PSX codebase
2. the content payload is heavily reduced compared with retail
3. some earlier architectural leftovers survived into this build, especially the split-`LoadExec` helper and the missing-file `TALK1.XA` path
4. the loader still expects a much larger planned level inventory than the current disc actually ships
That does **not** yet prove there is no meaningful `Crusader 2` gameplay/content divergence hidden deeper in the WDL data or untyped runtime tables. It does mean the first clean static answer is: this build is still much closer to `unfinished No Remorse PSX branch with reduced assets` than to an already distinctively rebranded sequel executable.
## Ghidra Updates From This Pass
Applied in the live `/psx/prealpha/SLUS_002.68` database:
- renamed `80038084` to `wdl_resource_bundle_load_by_index`
- added a disassembly comment at `80046aac` documenting the `LoadExec` targets `MENU.EXE`, `ENGINE.EXE`, and `PSX.EXE`
- added a disassembly comment at `80045648` documenting the `MULTI8.XA` vs `TALK1.XA` selection and the current missing-file mismatch
## Highest-Value Next Steps
1. Decompile and classify the callers around `80046aac` so the surviving `MENU.EXE` / `ENGINE.EXE` / `PSX.EXE` path can be labeled as boot flow, frontend transition, or dead leftover with less ambiguity.
2. Compare the pre-alpha `L0.WDL`, `L1.WDL`, and `L2.WDL` structure against the retail `LSET` extractor results to see whether they are just reduced No Remorse maps or genuinely divergent content/layout revisions.
3. Diff the pre-alpha `SPEC_A.WDL` and `MENUS/*.WDL` files against the retail PSX note to see whether the menu/front-end assets changed more than the executable strings did.
4. Trace the mission-briefing and passcode consumers directly in `/psx/prealpha/SLUS_002.68` to see whether the pre-alpha still uses the same mission ordering and password-generation rules as retail.
5. Search the pre-alpha executable for early script/event-dispatch tables or type/resource tables that differ from the retail PSX resource-stream model, especially around the WDL loader outputs.
6. Inspect `FMV.BIN` separately in Ghidra or as raw data to determine whether it still contains movie-system code/data even though the current disc ships no `.STR` files.
7. Run a first asset-focused comparison pass on the three shipped pre-alpha maps in the public renderer/export tooling once the bundle/resource bindings are stable enough to avoid the earlier invalid direct-bundle hypothesis.

806
docs/psx/psx.md Normal file
View file

@ -0,0 +1,806 @@
# Crusader: No Remorse (PlayStation) Recon
## Scope
- Target disc tree: `E:\emu\psx\Crusader - No Remorse`
- Goal of this pass: identify the boot executable, separate likely code from content, and find the most practical first extraction routes for PS1 assets.
## Immediate Conclusions
- `SYSTEM.CNF` is the disc boot file and points directly at `cdrom:\SLUS_002.68;1`.
- `SLUS_002.68` is the main game executable. It begins with a valid `PS-X EXE` header.
- No other top-level file currently looks like a second normal PS1 executable.
- Disc content is dominated by standard PS1 media (`.STR`, `.XA`) plus a large number of game-specific `.WDL` blobs.
- `FMV.BIN` looks like movie-playback support data or a resource blob, not a bootable executable.
- `ZZZ.ZZZ` looks much more like media/container data than code, and its size exactly matches `MOVIES/FMV3.STR`.
## Disc Boot Evidence
`SYSTEM.CNF` contents:
```ini
BOOT = cdrom:\SLUS_002.68;1
TCB = 4
EVENT = 10
STACK = 801FFFFC
```
`SLUS_002.68` header evidence:
- Magic: `PS-X EXE`
- Initial PC: `0x8004BD90`
- Load address: `0x80010000`
- Image size: `0x000A4800` (`675,840` bytes)
This is the clearest Ghidra import candidate for code analysis.
## Top-Level File Classification
Top-level items:
- `SLUS_002.68`
- `SYSTEM.CNF`
- `FMV.BIN`
- `ZZZ.ZZZ`
- `SPEC_A.WDL`
- `LEGAL.SCR`
- `LICENSEA.DAT`
- `AUDIO/`
- `MOVIES/`
- `MENUS/`
- `LSET1/` through `LSET7/`
Recursive extension summary from the extracted disc tree:
| Extension | Count | Total bytes | Current classification |
|---|---:|---:|---|
| `.STR` | 33 | 415,027,744 | PS1 movie streams |
| `.XA` | 2 | 91,897,856 | PS1 XA audio |
| `.WDL` | 66 | 76,198,860 | custom game asset/level blobs |
| `.ZZZ` | 1 | 26,148,984 | likely media/container data |
| `.68` | 1 | 675,840 | main PS1 executable |
| `.SCR` | 1 | 153,600 | data asset |
| `.BIN` | 1 | 90,812 | support/resource blob |
| `.DAT` | 1 | 28,032 | data asset |
| `.CNF` | 1 | 70 | boot config |
## File Family Findings
### 1. `SLUS_002.68`
- This is the boot target from `SYSTEM.CNF`.
- It has a normal PS1 executable header.
- It should be the first import into Ghidra.
- Current working assumption: this is the only primary native code binary on the disc.
### 2. `AUDIO/*.XA`
- Files found:
- `AUDIO/MULTI8.XA`
- `AUDIO/TALK1.XA`
- These are standard PS1 XA audio files.
- `MULTI8.XA` is divisible by both `2352` and `2048`, which is consistent with sector-oriented media data.
- `TALK1.XA` is divisible by `2048` but not exactly by `2352`.
- Most practical extraction route: use standard PS1/XA tooling rather than custom RE first.
### 3. `MOVIES/*.STR`
- Files found: `FMV0.STR` through `FMV32.STR`.
- These are the strongest candidates for standard PS1 video streams.
- The raw headers look consistent with sectorized PS1 stream data rather than executable code.
- Most practical extraction route: treat them as PS1 STR video and run them through PS1 media tooling first.
### 4. `FMV.BIN`
This file does not look like a normal executable.
First bytes begin with:
```text
\MOVIES\FMV%d.STR
MDEC_rest:bad option(%d)
MDEC_in_sync
MDEC_out_sync
DMA=(%d,%d), ADDR=(0x%08x->0x%08x)
FIFO=(%d,%d),BUSY=%d,DREQ=(%d,%d),RGB24=%d,STP=%d
```
Current best read:
- `FMV.BIN` is movie-related support data, code tables, or debug/resource text for the MDEC/FMVs.
- It clearly references the external movie path pattern `\MOVIES\FMV%d.STR`.
- It is worth a secondary Ghidra import only if the goal is to understand the movie subsystem specifically.
- It is not the disc boot executable.
### 5. `ZZZ.ZZZ`
Key findings:
- Size: `26,148,984` bytes
- That size exactly matches `MOVIES/FMV3.STR`.
- The file begins with stream-like binary data rather than an executable header.
- It also yielded movie-adjacent string evidence such as `MDEC`.
Current best read:
- `ZZZ.ZZZ` is probably not code.
- It is a strong candidate for either:
- a renamed movie stream, or
- a duplicate/alternate copy of `FMV3.STR`
Most practical next check:
1. compare a few sectors or hashes against `MOVIES/FMV3.STR`
2. try opening `ZZZ.ZZZ` directly in PS1 STR-capable tooling
### 6. `LSET*/L*.WDL`
These are the most important unknown asset family for content extraction.
Representative level sample: `LSET1/L0.WDL`
- Size: `1,312,624` bytes
- Header starts with structured values, not raw pixels:
```text
0x00000034 0x00006FDC 0x0000376C 0x00001000
0x00000160 0x00000498 0x0000025C 0x00000FE0
0x00000070 0x00072EC4 0x00034B6C 0x00007448
0x0007407C 0x00010824 0x00000002 0x00000000
```
- This does not behave like a flat framebuffer dump.
- It looks more like a custom structured level/container blob with internal offsets, lengths, or section pointers.
- Additional offset targets inside the file, such as `0x376C`, `0x6FDC`, and `0x10824`, land on repeating structured records rather than code.
Strict TIM-style scan results for `L0.WDL` found plausible embedded PS1 image headers at:
- `0xE7A84`
- `0x117DEC`
- `0x12CECC`
- `0x135F18`
- `0x1369F4`
- `0x136B38`
- `0x136C40`
Current best read:
- `LSET*.WDL` likely holds mixed level resources.
- At least some of those resources may include standard embedded PS1 TIM-like image blocks.
- These files are the strongest current target for a custom extractor.
Executable-guided extraction status:
- `lset_level_bundle_load` in the imported PSX executable now confirms the executable builds `\LSETn\Lx.WDL` paths directly and treats those files as the live level-bundle format.
- The same loader reads a small level header blob first, then a large SPU/audio blob, then dispatches the remaining level resource stream through `level_resource_stream_load`.
- `image_resource_bind_vram_slot` and `image_bundle_load_to_vram` show that resource types `4` and `5` are image/sprite-oriented resources: they resolve VRAM placement and upload image data through `LoadImage`.
- `sprite_rle_decode_rows` is now confirmed as the row-based decompressor used when a type-5 frame record has its compressed bit set.
- Current consequence: sprite extraction now has a real executable-backed path, while map extraction has a reliable raw-carving path even though the full tile/object semantics are not decoded yet.
### 7. `MENUS/*.WDL` and `SPEC_A.WDL`
Representative menu sample: `MENUS/M13.WDL`
- Size: `1,475,928` bytes
- Starts with dense repeating values like:
```text
0x9D499D29 0x9D299D29 0x9D4A9D4A 0xA14A9D4A
0x9D49A14A 0x9D299D29 0x9D4A9D4A 0xA14A9D4A
```
Representative special sample: `SPEC_A.WDL`
- Size: `545,424` bytes
- Starts with the same raw-looking pattern as `M13.WDL`.
Current best read:
- These do not begin with obvious pointer tables.
- They look more like raw or lightly wrapped image/screen asset data than like level containers.
- `M13.WDL` had no strict TIM hits in the quick scan.
- `SPEC_A.WDL` did have a few plausible stricter TIM-style hits at:
- `0x449A8`
- `0x80ED8`
So the `.WDL` family probably is not one single uniform format. Current evidence supports at least two subfamilies:
- structured level/resource blobs (`LSET*/*.WDL`)
- raw-looking menu/special screen blobs (`MENUS/*.WDL`, `SPEC_A.WDL`)
Executable-guided extraction status:
- `SPEC_A.WDL` does not behave like the `LSET*.WDL` container family.
- The executable-backed extractor work currently treats it as a raw blob with embedded image candidates rather than as a contiguous section table.
- A validated carve on `SPEC_A.WDL` currently finds strict TIM-style hits at `0x1B5CC` and `0x80ED8`.
## Executable-Backed Extraction Model
These findings are now grounded in both file inspection and the imported `SLUS_002.68` executable.
### Level bundles: `LSET*/*.WDL`
Validated current extraction model for `LSET1/L0.WDL`:
- top-level header size: `0x34`
- immediately following blob: `0x6FDC` bytes
- post-audio resource area starts at: `0x7010`
- high-confidence internal boundaries recovered from the header and validated with the extractor:
- `0x7448`
- `0x34B6C`
- `0x72EC4`
- `0x7407C`
Current carved regions from `L0.WDL`:
| Region | Offset | Size | Current interpretation |
|---|---:|---:|---|
| `audio_or_spu_blob` | `0x34` | `0x6FDC` | SPU/sequence data loaded by the audio init path |
| `post_audio_region_00` | `0x7010` | `0x438` | small table/directory block |
| `post_audio_region_01` | `0x7448` | `0x2D724` | strong map/placement candidate |
| `post_audio_region_02` | `0x34B6C` | `0x3E358` | strong map/placement candidate |
| `post_audio_region_03` | `0x72EC4` | `0x11B8` | small control/index block |
| `post_audio_region_04` | `0x7407C` | `0xCC6F4` | strongest current sprite/graphics bank candidate |
Important consequence:
- for map work, the best current extraction targets are `post_audio_region_01` and `post_audio_region_02`
- for sprite/graphics work, the best current extraction target is `post_audio_region_04`
Important correction from the next executable pass:
- a previously suspected text-like block in the broader PSX resource system is now confirmed separately in executable analysis as a menu/prompt text resource, not map data
- a heavily used level-side table is also now confirmed as a per-type flag/behavior table used by collision/order logic, not a raw map grid
- so the late-level extraction focus stays on `LSET*.WDL` post-audio regions, not on every large runtime table seen in the executable
The validated strict TIM carver currently finds one confirmed embedded TIM block in `L0.WDL` at:
- `0xBBA54`
That hit lands inside `post_audio_region_04`, which supports treating the late large region as the current best graphics bank candidate.
The same structure now reproduces on `LSET1/L1.WDL` too:
- header size: `0x34`
- audio blob: `0x3244`
- post-audio start: `0x3278`
- high-confidence boundaries: `0x6F48`, `0x334D8`, `0x602C4`, `0x732D4`
- late graphics candidate region: `0x732D4 .. EOF`
- current strict TIM hit: `0xB4DC8`
So the current working model is no longer based on just one level file: `LSET` bundles appear to share a stable pattern of:
1. fixed `0x34` header
2. SPU/audio blob
3. several map/meta candidate regions
4. one large late graphics-oriented region
What is still not stable yet:
- the internal semantics of `post_audio_region_01` and `post_audio_region_02` are still unresolved
- `L0.WDL` starts with rows that look structured when viewed as `u16x6`, but `L1.WDL` does not preserve the same obvious interpretation at the same region boundary
- current safest reading is that these are still raw candidate map/meta payloads, not yet a decoded placement format
### Menu / special blobs: `SPEC_A.WDL`, `MENUS/*.WDL`
These currently behave like raw image-oriented blobs, not like the structured `LSET` family.
Validated current extraction model for `SPEC_A.WDL`:
- whole-file raw blob fallback works cleanly
- strict TIM hits currently validate at:
- `0x1B5CC`
- `0x80ED8`
Representative secondary check on `MENUS/M13.WDL`:
- whole-file raw blob fallback also works cleanly there
- current strict TIM hit validates at:
- `0x493EC`
This keeps menus/special screens as a secondary image-carving problem instead of a map/container problem.
## Working Extractor
Current extractor script:
- `tools/psx_extract_wdl.py`
What it does right now:
- recognizes the validated `LSET*.WDL` top-level layout
- carves the audio blob and header-directed post-audio regions
- scans the whole file for strict TIM blocks and extracts them
- falls back to raw-blob carving for `SPEC_A.WDL` / menu-like files
- emits an exploratory `u16x6` CSV view for the first post-audio LSET candidate region so raw row patterns can be inspected without claiming final semantics
- scans the large late LSET graphics region for type-5 sprite bundle headers
- decodes row-RLE compressed sprite frames and writes raw frame payloads plus grayscale preview images
- writes carved output under `out/psx_wdl/<stem>/`
Current practical usage:
```powershell
c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/psx_extract_wdl.py "E:/emu/psx/Crusader - No Remorse/LSET1/L0.WDL"
c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/psx_extract_wdl.py "E:/emu/psx/Crusader - No Remorse/SPEC_A.WDL"
```
This is enough to start extracting:
- raw map-candidate blocks from level bundles
- strict TIM sprite/image blocks from both level and menu/special blobs
- exploratory raw row exports for the first LSET post-audio candidate region
- actual extracted sprite frames from at least some type-5 bundles inside the late LSET graphics region
Current caution:
- the `u16x6` export is only a raw inspection aid
- `L0.WDL` gives structured-looking rows such as `0041,177B,0F7F,0000,0002,0020`, but `L1.WDL` shows very different values at the same relative region
- so this export should be treated as evidence-gathering for map decoding, not as a solved object-placement parser yet
## Confirmed Sprite Extraction
The extractor now produces actual sprite-frame outputs from at least part of the late LSET graphics bank.
The late-graphics scan is now widened beyond the original first-32-bundle probe. Current `L0.WDL` extraction finds `159` candidate bundles in `post_audio_region_04`, and the larger-first overview now clearly includes not just floor/wall tiles but also several object/UI-like assets such as framed panels, cabinets, a portrait, a hand-shaped sprite, a bone, and other small pickup-like art.
Confirmed current example from `LSET1/L0.WDL`:
- graphics-region-relative bundle offset: `0xE5B8`
- whole-file bundle offset: `0x82634`
- mode: `2` (current best read: 4bpp indexed)
- frame count: `3`
- first extracted frame dimensions: `40 x 66`
- runtime default bundle palette index: `12`
Confirmed output files:
- raw frame bytes: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/frame_000.bin`
- preview image: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/frame_000.png`
- colored preview image: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/frame_000_color.png`
- colored sprite atlas: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/atlas_color.png`
- bundle metadata: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/bundle.json`
- palette metadata: `out/psx_wdl/L0/sprite_bundles/bundle_0000E5B8/palette.json`
Current result:
- this is no longer just a graphics-bank hypothesis
- the workflow can now extract actual sprite/image frame payloads and render preview images from at least some LSET graphics bundles
- the workflow can now also render colored previews and a proper per-bundle RGBA atlas using the bundle default palette index recovered from the PSX executable
- widened grayscale overviews now confirm that the late graphics bank contains recognizable object and UI art, not just texture/noise candidates
Current palette model:
- the executable-backed palette source for `LSET*.WDL` is the first `0x1000` bytes loaded into `DAT_800676d8` by `lset_level_bundle_load`
- `level_palette_upload_cluts` uploads that `0x1000` block as `8 x 16` raw 16-color CLUTs and then caches the raw CLUT handles in `DAT_800a9f48`
- `level_palette_expand_5bit_to_16color` separately builds a second `0x1000` grayscale-expanded table, but `DAT_800a9f48` is populated only from the first 8 raw rows
- the bundle header field at `+0x14` is the default palette-table index used when no override is supplied at draw time
- the remaining blocker is not locating the raw CLUT block anymore; it is recovering the per-placement palette override metadata that can replace the bundle default during map/tile rendering
Current color blocker:
- both main texture draw helpers (`FUN_80044bdc` and `FUN_80044e9c`) fall back to the bundle default palette index only when no override is present
- the important caller path at `FUN_80041458` ORs in a high-byte palette override from object/tile metadata pointed to by object field `+0xa0`
- that means standalone bundle previews can still be wrong even when the bundle parser and raw CLUT table are both correct
- the extractor now emits wider `u16x12` raw CSV views for `post_audio_region_01` and `post_audio_region_02` because the relevant override state appears to live beyond the first 6 words of those candidate placement records
- the current top-ranked portrait bundle (`bundle_00064478`, default palette index `106`) is a useful color-validation anchor because the grayscale frame is obviously correct while all raw-palette candidates remain visibly wrong
- another important unresolved issue is the exact on-disk location of the second-stage runtime header after the initial `0x3520` front image block. The loader assembly proves the runtime sequence is: `0x3520` front block -> `12`-byte header -> palette blob -> audio blob -> `4`-byte stream count, but the raw file bytes at offset `0x3520` do not yet reconcile cleanly with those expected sizes.
- `cd_file_read` itself does not transform or decompress bytes; it performs sector-based buffered CD reads. So the remaining palette-source problem is now narrowed to file-layout interpretation rather than hidden read-time decoding.
### Runtime Dump Grounding: cabinet console bundle
The new RAM/VRAM dump pair was used to ground the known-colored cabinet console bundle against live runtime state instead of continuing static palette guessing.
Verified cabinet anchor:
- bundle: `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04`
- mode: `1`
- frame `0`: `56 x 68`
- default bundle palette index: `0`
Verified live-texture result from `binary/Crusader - No Remorse (USA) GPU RAM.bin`:
- the frame payload from `bundle_000A1B04/frame_000.bin` exists in live VRAM as one exact `8bpp` texture match
- exact match location: texel `x=258`, `y=256`
- texture page: `(1,1)`
- in-page offset: `(2,0)`
- no flipped exact match was found
This is a strong confirmation that the current `mode 1` pixel decode is correct. The remaining problem is CLUT selection, not texture extraction.
Verified CLUT result from the same dump:
- the active CLUT band used by `level_palette_upload_cluts` still sits at rows `0xF0..0xF7`
- the important successful step was simpler than the later screen-match ranking pass: in `live_vram_clut_atlas.png`, the very first candidate at the top-left corner is the correct formula for this visible cabinet family
- that top-left candidate is the contiguous `256`-entry palette taken directly from live GPU row `0xF0` at `x=0`
- in other words, current best read for this `mode 1` family is: `byte value -> direct index into the 256-word slice [row 0xF0, x 0..255]`
- equivalently, this behaves like `16` adjacent live `16-color` CLUTs flattened into one `256`-entry lookup table for the sprite byte stream
- the later numeric ranking pass that preferred handle `64` / row `0xF4` was misleading for this case and should not be treated as the correct palette formula
Important consequence:
- the dump-grounded success case is not `bundle default row 0` from `L0.WDL` and not the later `row 0xF4` ranking result
- the working palette source is the live VRAM CLUT row `0xF0`, `x=0`, treated as one contiguous `256`-entry table
- this means the current extractor problem for `mode 1` bundles is better described as `recover the runtime CLUT-row formula` rather than `pick one cached CLUT handle index`
- for the visible wall-console bundle, that runtime formula now has a concrete verified answer even though the higher-level metadata path that selects it is still unresolved
Wider decode result using the corrected formula:
- a focused batch renderer was run over the detected `mode 1` bundles in `LSET1/L0.WDL` post-audio graphics region `04`
- using the same live palette source `row 0xF0 / x=0`, the pass rendered `92` `mode 1` bundles with plausible colored output instead of only the single cabinet proof case
- the strongest batch proof is the generated overview:
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/overview_live_row_f0_x0.png`
- per-bundle outputs and summary metadata now live under:
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/`
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/summary.json`
- that wider pass now shows many object-like assets decoding plausibly under the same rule: cabinets, panels, tanks, wall fixtures, floor markers, weapons, pickups, and small machinery props
Generated runtime-grounded artifacts:
- `binary/psx_framebuffer_left.png`
- `binary/psx_framebuffer_console_crop.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_atlas.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_top_matches.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_best.png`
- `out/psx_wdl/L0/sprite_bundles/bundle_000A1B04/live_vram_clut_rank.txt`
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/overview_live_row_f0_x0.png`
- `out/psx_wdl/L0/mode1_live_clut_row_f0_x0/summary.json`
One important caveat from the dump-grounded pass:
- none of the `8` raw `256-color` palette blocks carved from `LSET1/L0.WDL` matched the live CLUT rows byte-for-byte in this dump
- that means either the dump was captured from a different loaded level/resource set, or the active runtime palette source for this on-screen console is not the raw `L0.WDL` palette blob currently being tested
Palette follow-up note:
- most extracted PSX sprite data is now structurally correct, but palette selection is still only partially solved
- the current exporter should therefore be treated as `good enough to continue map work`, not as a final automatic color pipeline
- we need a later pass to recover the runtime palette-selection rule well enough to assign the correct palette automatically for every bundle family instead of relying on the currently verified `mode 1` rule plus heuristics
## PSX Map Decode Plan
Current objective:
- decode the PSX `LSET*.WDL` map/resource layout well enough to render PSX maps through the existing public map renderer pipeline instead of building a one-off viewer
Current working split:
- `post_audio_region_01` is now the first high-confidence map-placement candidate
- `post_audio_region_02` still looks more like compressed or mixed resource payload than directly renderable placement rows
- `post_audio_region_04` remains the late graphics bank and should be treated as the PSX art source for any eventual renderer integration
Immediate working hypothesis:
- `post_audio_region_01` is a fixed-row authored layout stream
- the raw `u16x12` view is already showing that each `24`-byte row behaves like two adjacent `6`-word records with similar field structure
- the strongest early evidence is that neighboring left/right halves carry similar value ranges and repeat the same small control words in the tail fields, which is what we would expect from paired cell/object placements rather than opaque compressed data
Practical renderer goal:
- adapt the PSX decode into the same broad source model the public renderer already uses for PC fixed maps: coordinates, shape/frame identity, and a few raw metadata bytes/words kept for inspection
- do not block on fully naming every field before producing a first renderer-fed PSX map source
Planned work order:
1. Lock down `post_audio_region_01` row structure across more `LSET` files and confirm whether `24` bytes is the true authored row size.
2. Separate the two half-rows into individual candidate placement records and track stable min/max ranges for each word position.
3. Identify which words are likely coordinates by checking for bounded map-like ranges and local spatial continuity between neighboring rows.
4. Identify which words are likely tile/object ids by checking whether the same values recur in ways that match repeated wall/floor/object motifs.
5. Correlate the placement stream against `post_audio_region_04` bundle offsets or bundle-local ids to recover the art linkage.
6. Determine whether `post_audio_region_02` is a secondary map layer, a lookup table for region `01`, or a different compressed resource class entirely.
7. Prototype a PSX map-source exporter that emits JSON in a renderer-friendly form even if some fields are still labeled as raw words.
8. Add a PSX-specific loader path to the existing map renderer instead of creating a separate PSX viewer.
9. Once the first map renders, iterate on field naming, layer semantics, and art binding rather than trying to solve the whole format up front.
Current evidence-backed next step:
- the extractor now needs to keep emitting a paired-record export for `post_audio_region_01` so the candidate row model can be checked quickly across multiple maps without reinterpreting the CSV by hand each time
Current renderer-compatibility result:
- a first PSX-compatible static real-art probe scene is now exported for the public map renderer
- exporter script:
- `tools/psx_export_map_debug_scene.py`
- current generated public-report outputs:
- `k:\ghidra\Crusader_Decomp_Public\map_renderer\site\data\maps\psx-remorse\map-0\scene.json`
- multiple copied frame atlases such as `k:\ghidra\Crusader_Decomp_Public\map_renderer\site\data\maps\psx-remorse\map-0\bundle_0003917C_frame_000.png`
- `k:\ghidra\Crusader_Decomp_Public\map_renderer\site\data\catalog.json`
- `k:\ghidra\Crusader_Decomp_Public\map_renderer\site\data\catalogs\psx-remorse.csv`
- current scene characteristics:
- source: filtered `LSET1/L0.WDL` `post_audio_region_01` paired-record candidates
- rendered items: `1050`
- unique bundle-backed shape definitions: `49`
- copied atlas/frame PNGs: `62`
- bounds: `3896 x 8431`
- scene format version: `psx-region01-bundle-probe-v1`
- current probe stats: `u0` span `62..111`, fallback frame count `187`
Current art-binding hypothesis used by this probe:
- region-01 `u0` is treated as a provisional direct bundle index into the extracted `sprite_bundles/` set
- region-01 `u4` is treated as a provisional frame index within that bundle, clamped to the highest available frame when out of range
- this is evidence-backed enough to render real PSX art in the existing map renderer, but not strong enough yet to call the binding solved
- the strongest negative check so far is that the region-01 `u5` values (`0x20`, `0x22`, `0x30`) do not match the bundle default palette indexes, so the palette-selection/control path is still missing
Current invalidation result:
- this direct `u0 -> bundle index` mapping is now considered invalid for real renderer output
- the produced scene repeats a small set of obviously wrong assets, including portrait/UI-like art, in places that do not make spatial sense for the map
- executable-side tracing shows that art selection is type-driven through `DAT_800758cc/d0/d4/d8` resource tables loaded by `level_resource_stream_load`, not by directly indexing the raw `post_audio_region_04` bundle scan
New loader/data evidence from this pass:
- `post_audio_region_00` now has dedicated extractor diagnostics:
- `out/psx_wdl/L0/post_audio_region_00_00007010_u16x6.csv`
- `out/psx_wdl/L0/post_audio_region_00_00007010_u16x12.csv`
- `out/psx_wdl/L0/post_audio_region_00_00007010_u32x5.csv`
- `out/psx_wdl/L0/post_audio_region_00_00007010_stream_probe.json`
- the new raw probe confirms that `post_audio_region_00` begins with a little-endian count value `0x20`
- after an initial short header/preamble, the bytes from about `0x3c` onward look like tightly packed `12`-byte records in the same broad shape family as the old candidate placement rows:
- example bytes at `0x3c`: `4a 00 03 16 e7 0e 00 00 01 00 20 00`
- little-endian words: `0x004A, 0x1603, 0x0EE7, 0x0000, 0x0001, 0x0020`
- that record family is a better next target than the invalidated direct bundle probe because it already exposes a small type-like word (`0x004A`) plus coordinate-like words without forcing an arbitrary raw-bundle index
What this first public renderer pass means:
- the existing renderer app can now load a PSX scene bundle from the static report without any PC `FIXED.DAT` dependency
- this is currently a real-art probe of filtered placement candidates, not a final decoded PSX map
- the renderer now displays extracted bundle art from `post_audio_region_04` instead of synthetic colored stand-ins
- the current output is still useful because it shows that filtered region-01 records can drive recognizable, repeatedly used PSX art through the existing renderer pipeline
- one bad extracted origin (`1x6` sprite with `xoff=65535`) initially blew out the fit bounds; the exporter now sanitizes implausible origins before writing scene metadata
Current app compatibility notes:
- the public renderer app was updated so non-`FIXED.DAT` map sources do not advertise a bogus binary export path
- for the PSX probe scene, `Download Map Binary` is intentionally disabled while `Download PNG`, `Download Map JSON`, and `Download Atlas PNG` remain available
- the static app successfully loads the `PSX LSET1/L0 Region 01 Art Probe` catalog entry and currently fits it at about `8%` zoom instead of the earlier collapsed `2%` fit
Immediate implications for the next decode pass:
- the public renderer integration path is now proven enough to use as a live debug target for PSX map-format work
- the next priority is to replace the invalidated `u0 -> bundle index` hypothesis with a real type/resource lookup recovered from `level_resource_stream_load`
- `post_audio_region_00` is now a top-tier candidate for that work because its new diagnostics expose a count-prefixed preamble plus compact typed records that look more loader-compatible than the old region-01 art probe
- the palette override path is still the main blocker to correct final color selection even when the bundle/frame choice is plausible
- once the bundle key and palette control path are recovered, the same scene-export path can graduate from `real-art probe` to actual PSX map rendering
## PSX Script / Usecode Equivalent
Current status:
- there is no evidence yet that the PSX build carries the exact same external `USECODE`/`EUSECODE.FLX` style asset pipeline used by the DOS version
- the current PSX executable-backed work has mostly exposed compiled resource loaders, animation/audio handlers, and image upload/decode paths rather than a separate obvious bytecode container
Current working question:
- the likely PSX equivalent, if one exists, may be either:
- compiled gameplay logic directly inside `SLUS_002.68`, or
- a separate embedded event/script resource format inside the `LSET`/other disc blobs that is not yet isolated
Immediate plan:
1. scan the PSX executable and current renamed function set for script/event-dispatch terminology or obvious VM-style control loops
2. compare any candidate dispatch path against the DOS usecode model only at the behavioral level, not by assuming the asset format is shared
3. keep this as a secondary track while map decoding takes priority
## Practical Extraction Paths
### Standard media first
The easiest wins are the standard PS1 media formats:
- `MOVIES/*.STR`: treat as PS1 video streams
- `AUDIO/*.XA`: treat as XA audio
- `ZZZ.ZZZ`: try as a movie stream too, especially against `FMV3.STR`
This does not need custom reverse engineering first.
### Custom `.WDL` extraction second
The `.WDL` files are the main custom-content frontier.
Current executable-backed extraction order:
1. run `tools/psx_extract_wdl.py` over representative `LSET*.WDL` files
2. treat `post_audio_region_01` and `post_audio_region_02` as the current best map-data extraction targets
3. treat `post_audio_region_04` as the current best sprite/graphics extraction target
4. carve any strict TIM blocks first, because those now have executable support via the type `4` / type `5` image handlers
5. separately carve `SPEC_A.WDL` / `MENUS/*.WDL` as raw image-oriented blobs
The level files and menu/special files should not be assumed to share one parser until that is proven.
## Recommended Ghidra Import Candidates
### Primary
1. `E:\emu\psx\Crusader - No Remorse\SLUS_002.68`
Reason:
- confirmed by `SYSTEM.CNF`
- valid `PS-X EXE`
- main native code image
### Secondary, only if useful for subsystem RE
2. `E:\emu\psx\Crusader - No Remorse\FMV.BIN`
Reason:
- clearly tied to FMV playback
- contains path and MDEC-related strings
- could be worth importing as a raw binary/data blob if the movie subsystem becomes a target
### Not primary code imports
These currently look like content, not executables:
- `E:\emu\psx\Crusader - No Remorse\ZZZ.ZZZ`
- `E:\emu\psx\Crusader - No Remorse\SPEC_A.WDL`
- `E:\emu\psx\Crusader - No Remorse\LSET1\L0.WDL`
- `E:\emu\psx\Crusader - No Remorse\MENUS\M13.WDL`
They may still be worth loading as raw binaries later for format RE, but they are not first-choice code imports.
## Current Working Model
- `SLUS_002.68` = main PS1 executable
- `FMV.BIN` = FMV helper/support blob
- `MOVIES/*.STR` = standard movie streams
- `AUDIO/*.XA` = standard XA audio
- `ZZZ.ZZZ` = likely renamed or duplicated movie stream data
- `LSET*.WDL` = structured level/resource containers
- `MENUS/*.WDL` and `SPEC_A.WDL` = raw-looking screen/menu resource blobs, possibly with some embedded standard PS1 image content
## Executable Catalog Findings
This batch focused on the imported `SLUS_002.68` executable as a catalog source rather than on the raw `WDL` bundles alone.
### Map inventory and mission-facing structure
Current executable-backed map findings:
- `wdl_resource_bundle_load_by_index` now has a direct string-backed proof for the shipped folder layout. The loader copies one of seven hardcoded path prefixes `\LSET1\L` through `\LSET7\L` based on map-index thresholds `10`, `20`, `30`, `40`, `50`, and `60`, then formats the final `.WDL` path.
- The extracted disc tree currently ships `62` level bundles total:
- `LSET1`: `L0` through `L9`
- `LSET2`: `L10` through `L19`
- `LSET3`: `L20` through `L29`
- `LSET4`: `L30` through `L39`
- `LSET5`: `L40` through `L49`
- `LSET6`: `L50` through `L58`
- `LSET7`: `L62` through `L64`
- So the shipped PSX map-bundle range is `L0..L64` with a real on-disc gap at `L59..L61`.
- The executable also preserves only `15` plain-text `Mission Briefing ^Mission N` strings, for `Mission 1` through `Mission 15`.
Current safest read:
- the PSX disc contains `62` shipped map/resource bundles used by the `LSET` loader
- the player-facing campaign/briefing flow exposed by the executable is `15` numbered missions
- any extra bundle coverage beyond that mission-facing set is currently better treated as lower-level map/resource inventory, not automatically as `15 == all shipped WDLs`
Per-bundle shipped inventory from the extracted disc tree:
| Bundle range | Folder | Count | Size range (bytes) |
|---|---|---:|---:|
| `L0..L9` | `LSET1` | 10 | `987,932 .. 1,312,624` |
| `L10..L19` | `LSET2` | 10 | `1,107,380 .. 1,314,992` |
| `L20..L29` | `LSET3` | 10 | `904,384 .. 1,221,556` |
| `L30..L39` | `LSET4` | 10 | `1,104,316 .. 1,321,656` |
| `L40..L49` | `LSET5` | 10 | `1,120,084 .. 1,303,732` |
| `L50..L58` | `LSET6` | 9 | `1,012,956 .. 1,341,684` |
| `L62..L64` | `LSET7` | 3 | `965,072 .. 1,150,428` |
### Passcodes and password-screen cheat status
Current executable-backed passcode findings:
- The mission-complete passcode display path at `80022cd4` and `80022f1c` synthesizes a `4`-character code from generated indexes.
- Those indexes are mapped through the hardcoded alphabet at `80063ef0`:
```text
BCDFGHJKLMNPQRSTVWXZ0123456789
```
- The resulting `4` characters are written into the temporary display buffer at `80063f6e..80063f71`, null-terminated at `80063f72`, and shown through the completion message at `80063f10`:
```text
^Congratulations!^ You have completed your mission.^^The passcode for the next mission is:^
```
- So PSX mission passcodes are definitely real executable-generated `4`-character values, not just external manual text.
Current best password-screen cheat list from public PSX references:
- `XXXX` = hidden pictures
- `L0SR` or `L0SER` = cheat-mode password reported by public sources; the conflicting transcription is almost certainly a `0` vs `O` issue and is not yet closed directly from the executable
Important executable-side caveat:
- none of the known public PSX mission passwords checked in this pass (`FWQP`, `HWQP`, `LRTN`) appear as plain ASCII strings inside `SLUS_002.68`
- the same is true for the public cheat-password candidates `XXXX`, `L0SR`, and `L0SER`
- current safest read is therefore `password entry and/or validation is numeric or transformed`, not `a plain embedded string table of passcodes`
- this pass closed the visible generation/display side, but it did **not** yet directly close the hidden cheat-password compare path
### Weapons and items
The executable does preserve user-facing text tables for equipment.
Recovered ammo names:
- `INVALID AMMO`
- `JL-2 AMMO`
- `AR-7 AMMO`
- `GL-303 AMMO`
- `RP-22 AMMO`
- `SG-A1 AMMO`
Recovered item names:
- `NULL ITEM`
- `INHIBITOR`
- `CREDITS`
- `SCI PLANS`
- `BLAST PAC`
- `DET PAC`
- `DATA LINK`
- `LAND MINE`
- `SPIDER BOMB`
- `MEDICAL KIT`
- `ENERGY CUBE`
- `FUSION PAC`
- `CHEMICAL BATTERY`
- `FISSION BATTERY`
- `FUSION BATTERY`
- `GRAVITON GENERATOR`
- `IONIC GENERATOR`
- `PLASMA GENERATOR`
Recovered weapon names:
- `RP-16`
- `RP-22`
- `RP-32`
- `SG-A1`
- `AC-88`
- `PA-31`
- `EM-4`
- `PL-1`
- `UV-9`
- `GL-303`
- `AR-7`
- `JL-2`
- `JL-9`
Current safest read:
- these are real executable-backed display-name tables, not guessed carryovers from the DOS build
- the PSX build still uses a recognizable Crusader equipment taxonomy even where some item labels differ from the more familiar DOS-side vocabulary
JL-2 / JL-9 follow-up:
- neither `JL-2` nor `JL-9` appears in the known DOS `Weapon_GetNameForShapeNo` tables already extracted in this repo for retail Remorse or Regret; those tables stop at the older DOS weapon families such as `BA-40`, `BA-41`, `PA-21`, `EM-4`, `SG-A1`, `RP-22`, `RP-32`, `AR-7`, `GL-303`, `PA-31`, `PL-1`, `AC-88`, `UV-9`, and the Regret-only additions `BK-16`, `LNR-81`, `XP-5`
- that makes `JL-2` and `JL-9` strong PSX-only naming additions rather than inherited PC names
- `JL-2` is also the only one of the two with an explicit PSX ammo label (`JL-2 AMMO`) in the nearby executable text table, while no matching `JL-9 AMMO` string has been recovered
- the extracted PSX `pickups_and_weapons` sprite category contains repeated weapon-pickup art across a large spread of maps, but this pass still does not have a defensible sprite-to-name mapping for specific `JL-2` or `JL-9` pickup appearances
### Enemies
This pass did **not** recover a comparable plain-text enemy-name table from `SLUS_002.68`.
What is closed:
- the PSX executable has clean user-facing text for mission briefings, passcode UI, ammo, items, and weapons
- the same executable does **not** expose an equally obvious plain-text enemy catalog in its main printable-string regions
Current safest read:
- enemy identities in the PSX build are probably carried primarily as numeric resource/type ids, spawn tables, or script/resource references rather than as a direct display-name list
- the next enemy-focused pass should start from enemy spawn/type dispatch or resource-stream type tables, not from more blind string hunting
## Highest-Value Next Steps
1. Run `tools/psx_extract_wdl.py` over more `LSET*.WDL` samples and compare whether the high-offset region pattern stays stable across level sets.
2. Recover the password-entry validation path directly so the hidden PSX cheat-password compare logic can be proven from code instead of only cross-referenced from public password lists.
3. Focus map decoding on `post_audio_region_01` and `post_audio_region_02`, starting with table structures, coordinate ranges, and repeated record widths.
4. Focus sprite/graphics decoding on `post_audio_region_04`, including more aggressive TIM validation and possible packed-image expansion.
5. Recover the exact type IDs consumed by `level_resource_stream_load` so the sprite/image resource records can be labeled more precisely.
6. Compare carved `post_audio_region_04` image assets against on-screen level graphics to separate sprite sheets from tiles.
7. Run the raw-blob fallback across `MENUS/*.WDL` to identify which menu files contain usable embedded TIM data and which are likely packed 15-bit images.

View file

@ -119,6 +119,30 @@ The key parser detail is that there are no separately recovered `-x`, `-y`, or `
There is one important precedence rule in `Game_RunNewGameFlow`: `-egg` wins over the coordinate override path. When X is present but the egg override is nonnegative, the code still routes back into the egg-based teleporter lane. The direct `NPC_Teleport` path only runs when X/Y/Z are present and the egg override is still negative. There is one important precedence rule in `Game_RunNewGameFlow`: `-egg` wins over the coordinate override path. When X is present but the egg override is nonnegative, the code still routes back into the egg-based teleporter lane. The direct `NPC_Teleport` path only runs when X/Y/Z are present and the egg override is still negative.
The live `REGRET.EXE` table backing that `-warp mission` branch is now closed directly too.
In `Game_RunNewGameFlow`, the target map comes from:
```c
mapno = *(int *)(g_warpToLevelNoArg * 2 + 0x75c) + DAT_1480_0ad0;
```
So the current retail No Regret mission-table base is `1480:075c`.
Static bytes at that address:
```text
1480:075c: 00 00 01 00 03 00 05 00 07 00 09 00 0b 00 0d 00
1480:076c: 0f 00 11 00 13 00 15 00 17 00 19 00 1b 00 1d 00
1480:077c: 28 00
```
Interpreted as little-endian words, the current recovered base-map table is:
- `0, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 40`
That is the same 17-word sequence already recovered from No Remorse at `1478:0488`, so the two games currently match on the embedded mission-to-base-map table even though the surrounding startup control flow differs.
## Could Parameters Replace The Executable Hack? ## Could Parameters Replace The Executable Hack?
For a normal fresh game, no. For a normal fresh game, no.

View file

@ -17,6 +17,8 @@ The working split is:
- Remorse usecode export: `Crusader_Decomp_Public/USECODE_REMORSE/**` - Remorse usecode export: `Crusader_Decomp_Public/USECODE_REMORSE/**`
- Regret usecode export: `Crusader_Decomp_Public/USECODE_REGRET/**` for comparison only - Regret usecode export: `Crusader_Decomp_Public/USECODE_REGRET/**` for comparison only
- Retail disassembly export: `Crusader_Decomp/exports/CRUSADER.EXE.xml` - Retail disassembly export: `Crusader_Decomp/exports/CRUSADER.EXE.xml`
- Generated retail dtable dump: `Crusader_Decomp/tools/dump_dtable_names.py` -> `Crusader_Decomp/out/dtable_get_name_dump.json` plus companion CSVs
- Generated Regret dtable dump: `Crusader_Decomp/tools/dump_dtable_names.py regret` -> `Crusader_Decomp/out/regret_dtable_get_name_dump.json` plus companion CSVs
## High-Level Conclusions ## High-Level Conclusions
@ -51,7 +53,7 @@ Current best read: `0343` is a surviving but heavily stripped grenade-family obj
- `LAND MINE` - `LAND MINE`
- `BLAST PAC` - `BLAST PAC`
- `FUSION PAC` - `FUSION PAC`
- The repeated `INVALID` placeholders inside that same array are also informative: `1478:22BC` is a `char *[41]` table, and slots `0`, `15`, `27`, and `33` all point to the same `INVALID` string. The removed grenade names live immediately after one of those placeholder boundaries. - The repeated `INVALID` placeholders inside that same array are also informative: the verified retail dump from `tools/dump_dtable_names.py` shows `1478:22BC` is a `char *[41]` table whose global slots `0`, `14`, `26`, and `32` all point to the same `INVALID` string. The removed grenade names then occupy bomb-family slots `33..40` as `GRENADE`, `CONCUSSION GRENADE`, `NERVE GAS GRENADE`, `EMP GRENADE`, `SPIDER BOMB`, `LAND MINE`, `BLAST PAC`, and `FUSION PAC`.
- This means the removed grenade family is not just a renderer/catalog naming artifact. The retail executable still knows those display strings even though the distinct Remorse scripted classes appear to be gone. - This means the removed grenade family is not just a renderer/catalog naming artifact. The retail executable still knows those display strings even though the distinct Remorse scripted classes appear to be gone.
Current best read: these are old grenade-family placements left in maps and editor-era data whose exact inventory/display names still survive in the retail executable, but whose distinct Remorse behavior has likely been removed or collapsed away before retail. The user-observed behavior where some can be forced into inventory but do nothing is consistent with a leftover shape/name path surviving after the actual item logic was cut. Current best read: these are old grenade-family placements left in maps and editor-era data whose exact inventory/display names still survive in the retail executable, but whose distinct Remorse behavior has likely been removed or collapsed away before retail. The user-observed behavior where some can be forced into inventory but do nothing is consistent with a leftover shape/name path surviving after the actual item logic was cut.
@ -65,7 +67,7 @@ Current best read: these are old grenade-family placements left in maps and edit
- its fallback branch at `1118:05D5` returns `DS:238C`, which is the string `INVALID` - its fallback branch at `1118:05D5` returns `DS:238C`, which is the string `INVALID`
- `Weasel_OnPaint` at `13E0:0932` calls `DTable_GetNameForShapeNo`, so this is a real inventory/display UI path rather than an isolated dead string helper - `Weasel_OnPaint` at `13E0:0932` calls `DTable_GetNameForShapeNo`, so this is a real inventory/display UI path rather than an isolated dead string helper
- The backing data segment confirms the same model. In segment `1478`, the item-name table at `1478:22BC` is a `char *[41]` array whose placeholder slots point at the `INVALID` string at `1478:238C`. - The backing data segment confirms the same model. In segment `1478`, the item-name table at `1478:22BC` is a `char *[41]` array whose placeholder slots point at the `INVALID` string at `1478:238C`.
- I still have not closed the exact shape-to-table-index mapping for `0548`, but the retail executable evidence now cleanly supports the user report that at least some bad/unmapped inventory entries resolve to visible label text `Invalid` in-game. - The verified dump also closes one open point: `0x0548` does **not** appear in the resolved dtable category tables at all. Its visible in-game `Invalid` label is therefore best explained as a plain `DTable_GetNameForShapeNo` fallback for an unmapped shape, not as a dedicated `0548` name-table entry.
Current best read: `0548` is a real leftover item-like record that still resolves through at least one executable-side text/name path, but no meaningful use behavior has been recovered yet. Current best read: `0548` is a real leftover item-like record that still resolves through at least one executable-side text/name path, but no meaningful use behavior has been recovered yet.
@ -101,6 +103,26 @@ The retail name table behind `DTable_GetNameForShapeNo` was useful beyond the or
Current best read: these are active explosive inventory objects, not additional removed-item discoveries. The current catalog simply does not label all of them clearly. Current best read: these are active explosive inventory objects, not additional removed-item discoveries. The current catalog simply does not label all of them clearly.
### Reusable dump status
- The retail `DTable_GetNameForShapeNo` mapping is now exported in reusable form for downstream tooling:
- `out/dtable_get_name_dump.json`
- `out/dtable_global_name_slots.csv`
- `out/dtable_category_entries.csv`
- `out/dtable_resolved_shapes.csv`
- That dump is generated by `tools/dump_dtable_names.py` directly from retail `CRUSADER.EXE` bytes plus `exports/CRUSADER.EXE.xml` relocations, so it is suitable for feeding the map renderer and shape catalogs without keeping the mapping only in prose notes.
### Regret-side DTable comparison
- The same dumper now also supports live `REGRET.EXE`, with the recovered helper at `1130:056a` and the Regret dtable island in segment `1480`.
- The Regret dump closes the shifted layout directly from raw bytes even without a local `REGRET.EXE.xml` export: pointer table `1480:2856`, string pool `1480:2926`, and category tables at `1480:2590` (ammo), `1480:2614` (weapons), `1480:2792` (misc items), and `1480:27DE` (bombs).
- Regret also repeats a single `INVALID` string through four global slots, but at a different expanded layout: indices `0`, `17`, `36`, and `44` all point back to `1480:2926`.
- That expanded Regret table preserves several names that do not appear in the earlier Remorse dump, including weapons `BK-16`, `LNR-81`, `XP-5`; items `IONIC SHIELD`, `PLASMA SHIELD`, `DISRUPTER`, `SPIDER MINE`, `DATA PICK`, `MINE DET`, `PORTABLE BETTY`, `RADIATION SHIELD`, and `VIR IMAGER`; and ammo `BK-16 CLIP`, `LNR-81 CLIP`.
- The resolved Regret bomb rows are also narrower than a naive string scan suggests: `0x0343` = `GRENADE`, `0x0350` = `EMP GRENADE`, `0x0560` = `SPIDER BOMB`, `0x039A` = `LAND MINE`, and `0x039C` = `FUSION PAC`.
- `BLAST PAC` is notably absent from the recovered Regret dtable output. In other words, the Regret executable still carries `LAND MINE` and `FUSION PAC`, but not a `BLAST PAC` bomb-table slot analogous to the Remorse block.
Current best read: the Regret comparison strengthens the claim that these explosive names come from a real retail inventory/display lookup family rather than renderer-only catalog text, but it also shows that the two games do not preserve the exact same bomb/item naming inventory.
### Spider bomb: stronger Regret-side evidence than Remorse-side evidence ### Spider bomb: stronger Regret-side evidence than Remorse-side evidence
- The same retail name table includes `SPIDER BOMB`. - The same retail name table includes `SPIDER BOMB`.
@ -144,7 +166,7 @@ Current best read: these are real leftover placeholder objects worth a future de
## Open Questions ## Open Questions
1. What exact shape or category values land on the four `INVALID` placeholder slots in the retail `1478:22BC` dtable array, and is `0548` one of them directly? 1. What code path allows `0548` and similar bad/unmapped shapes to reach the inventory/display UI if they are not direct entries in the retail `1478:22BC` dtable mapping?
2. Do `034E`, `034F`, and `0350` still have dead executable-side item-definition records even though no separate Remorse usecode classes were recovered? 2. Do `034E`, `034F`, and `0350` still have dead executable-side item-definition records even though no separate Remorse usecode classes were recovered?
3. Is the observed forced-pickup behavior using a generic item acquisition path that bypasses normal `invitem` rules and therefore reaches the `DTable_GetNameForShapeNo` fallback path? 3. Is the observed forced-pickup behavior using a generic item acquisition path that bypasses normal `invitem` rules and therefore reaches the `DTable_GetNameForShapeNo` fallback path?
4. What are `0110` and `0112` visually or functionally in the old maps, beyond being non-pickup leftovers? 4. What are `0110` and `0112` visually or functionally in the old maps, beyond being non-pickup leftovers?

View file

@ -30,6 +30,9 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan
- The latest USECODE pass justified another small VM-lane bump: the gameplay-side wrapper ladder now extends through slots `0x10..0x14` with verified mixed payload shapes (`none` vs extra signed word), the new slot-only Ghidra names keep that taxonomy visible without overpromoting event labels, and the `000d:22bc` stage is now comment-backed as a sequencer-internal link-matrix/pushback consumer over decoded workspace bytes rather than a direct descriptor-row reader. - The latest USECODE pass justified another small VM-lane bump: the gameplay-side wrapper ladder now extends through slots `0x10..0x14` with verified mixed payload shapes (`none` vs extra signed word), the new slot-only Ghidra names keep that taxonomy visible without overpromoting event labels, and the `000d:22bc` stage is now comment-backed as a sequencer-internal link-matrix/pushback consumer over decoded workspace bytes rather than a direct descriptor-row reader.
- The immortality follow-up justified another small tooling-and-confidence bump: the extractor now emits a dedicated target-body scan, the strongest current USECODE candidates show no inline `0x410` / `0x00000410` literal, and the remaining frontier is narrowed to data-driven decoding of `EVENT` slot `0x0a` plus `NPCTRIG` slots `0x0a` / `0x20` rather than the older wider trigger family set. - The immortality follow-up justified another small tooling-and-confidence bump: the extractor now emits a dedicated target-body scan, the strongest current USECODE candidates show no inline `0x410` / `0x00000410` literal, and the remaining frontier is narrowed to data-driven decoding of `EVENT` slot `0x0a` plus `NPCTRIG` slots `0x0a` / `0x20` rather than the older wider trigger family set.
- The latest owner-loaded range pass justified another small confidence bump too: the owner-resource child selector now matches extracted `class_id + 2` exactly, the class header/subentry math at `000d:5066/51fd/53b4` is closed against the extractor's raw headers and event rows, and the surviving immortality uncertainty has moved from `can the loader fit NPCTRIG arithmetic at all?` to the narrower `which class family is actually selected upstream?` question. - The latest owner-loaded range pass justified another small confidence bump too: the owner-resource child selector now matches extracted `class_id + 2` exactly, the class header/subentry math at `000d:5066/51fd/53b4` is closed against the extractor's raw headers and event rows, and the surviving immortality uncertainty has moved from `can the loader fit NPCTRIG arithmetic at all?` to the narrower `which class family is actually selected upstream?` question.
- The PSX sprite-extraction side is also less speculative now: a dump-grounded pass proved the known-colored wall-console bundle `bundle_000A1B04` already exists verbatim in live VRAM at texture page `(1,1)`, and the corrected working color formula is the top-left live CLUT candidate from the atlas, namely the contiguous `256`-entry slice at GPU row `0xF0`, `x=0`; the same rule now produces plausible output across a wider `92`-bundle `mode 1` batch instead of only the single cabinet proof case.
- The PSX executable-side catalog lane is tighter too: `SLUS_002.68` now has comment-backed proof that `wdl_resource_bundle_load_by_index` selects seven hardcoded `\LSETn\L` prefixes across thresholds `10/20/30/40/50/60`, the extracted disc currently ships `62` level bundles (`L0..L58`, `L62..L64`) with a real gap at `L59..L61`, the executable exposes only `15` plain-text `Mission Briefing ^Mission N` strings, and the mission-complete passcode path now has a closed `4`-character consonant/digit alphabet at `80063ef0` plus direct ammo/item/weapon name tables. The remaining PSX passcode gap is now narrower: public cheat-password candidates `XXXX` and `L0SR`/`L0SER` are not stored as plain ASCII in `SLUS_002.68`, so the compare path likely uses numeric or transformed validation instead of a flat string table.
- The new PSX pre-alpha comparison lane is also anchored now: `/psx/prealpha/SLUS_002.68` still carries direct `Crusader: No Remorse` branding, the same retail-style `wdl_resource_bundle_load_by_index` `\LSET1\L .. \LSET7\L` threshold ladder, and the same `15` mission-briefing/passcode shell, but the unpacked `Crusader 2 Pre-Pre Alpha` disc currently ships only `3` level bundles, `1` XA, and no `.STR` movies. The most interesting current mismatches are architectural leftovers that no longer match the disc literally, especially the missing-file `\AUDIO\TALK1.XA;1` path and the surviving `LoadExec` helper for `MENU.EXE` / `ENGINE.EXE` / `PSX.EXE`.
- That closes one live top-priority section and justifies a small headline increase even though the remaining work is still breadth-heavy. - That closes one live top-priority section and justifies a small headline increase even though the remaining work is still breadth-heavy.
## Current Verified State ## Current Verified State
@ -49,11 +52,14 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan
- 000a/000d tracked-handle, cache, allocator, dispatch-entry, and startup/display support lanes now have a coherent partial map. - 000a/000d tracked-handle, cache, allocator, dispatch-entry, and startup/display support lanes now have a coherent partial map.
- 000e parser and animation subsystems have a real partial map. - 000e parser and animation subsystems have a real partial map.
- The auxiliary local disassembly corpus at `K:/ghidra/crusader-disasm` is now inventoried and integrated as a separate evidence source for shape metadata, static map/object dumps, opcode names, and older Remorse/Regret intrinsic-function vocabularies; its safe-reuse rules and porting implications are captured in `docs/crusader-disasm-reference.md`. - The auxiliary local disassembly corpus at `K:/ghidra/crusader-disasm` is now inventoried and integrated as a separate evidence source for shape metadata, static map/object dumps, opcode names, and older Remorse/Regret intrinsic-function vocabularies; its safe-reuse rules and porting implications are captured in `docs/crusader-disasm-reference.md`.
- The PSX side now has a first explicit pre-alpha comparison note too. `docs/psx/prealpha.md` records that `/psx/prealpha/SLUS_002.68` is still much closer to a reduced No Remorse PSX branch than to a visibly rebranded sequel executable: the live database now has `wdl_resource_bundle_load_by_index` renamed at `80038084`, and comment-backed notes on the stale `TALK1.XA` selector helper and the split-`LoadExec` `MENU.EXE` / `ENGINE.EXE` / `PSX.EXE` path that no longer matches the current unpacked disc tree.
- The workspace now also has a first dedicated offline map-rendering/tooling lane: `tools/render_crusader_map.py` can load a chosen `FIXED.DAT`, expand `GLOB.FLX`, decode required `SHAPES.FLX` frames, apply `GAMEPAL.PAL`, and emit a first-pass PNG from either static set, while `docs/map-rendering.md` captures the current format contracts, the `--fixed-dat` override, and the intentionally limited compositor model. - The workspace now also has a first dedicated offline map-rendering/tooling lane: `tools/render_crusader_map.py` can load a chosen `FIXED.DAT`, expand `GLOB.FLX`, decode required `SHAPES.FLX` frames, apply `GAMEPAL.PAL`, and emit a first-pass PNG from either static set, while `docs/map-rendering.md` captures the current format contracts, the `--fixed-dat` override, and the intentionally limited compositor model.
- The map/editor-visibility lane is now tighter too. New note `docs/editor-object-visibility.md` records live `CRUSADER.EXE` proof that the downstream item draw helper `1198:02e4` (`Item_PaintSprite`) explicitly returns early on `ShapeData.flags2 & 1` (`SI_EDITOR`), but the follow-up render-path pass also found the controlling upstream skip at `1180:0951..095c` in the world-item builder. Current best read is therefore `editor-tagged shapes are filtered before draw-node allocation in the normal world-item renderer, with a second downstream paint-time guard still present`, which also explains why a first patch that only flipped `1198:033b` produced no visible change in-game. No recovered retail `-debug`, cheat/debug hotkey, Laurie/usecode-debugger path, or `0x410` lane currently re-enables those objects. The closest confirmed toggle remains ScummVM's own `_showEditorItems` debugger command, which is engine-added rather than retail. - The map/editor-visibility lane is now tighter too. New note `docs/editor-object-visibility.md` records live `CRUSADER.EXE` proof that the downstream item draw helper `1198:02e4` (`Item_PaintSprite`) explicitly returns early on `ShapeData.flags2 & 1` (`SI_EDITOR`), but the follow-up render-path pass also found the controlling upstream skip at `1180:0951..095c` in the world-item builder. Current best read is therefore `editor-tagged shapes are filtered before draw-node allocation in the normal world-item renderer, with a second downstream paint-time guard still present`, which also explains why a first patch that only flipped `1198:033b` produced no visible change in-game. No recovered retail `-debug`, cheat/debug hotkey, Laurie/usecode-debugger path, or `0x410` lane currently re-enables those objects. The closest confirmed toggle remains ScummVM's own `_showEditorItems` debugger command, which is engine-added rather than retail.
- The localized-build comparison lane now covers the Japanese Windows-native executable too. New note `docs/jp-remorse-windows9x-investigation.md` records that `/ja/CRUSADER.EXE` is a PE-style Win32 image with native window creation, DirectDraw/DirectSound init, registry-backed config under `Software\Electronic Arts\Crusader: No Remorse\J1.21`, IME/DBCS-facing imports, and a `GetVersion`-driven Win9x compatibility branch that retries `TlsAlloc()` until the slot is above `2` when the classic Win9x version bit is set. Current best read is `real Windows 9x-native port with likely Win95 intent`, with runtime prerequisites still left to test. - The localized-build comparison lane now covers the Japanese Windows-native executable too. New note `docs/jp-remorse-windows9x-investigation.md` records that `/ja/CRUSADER.EXE` is a PE-style Win32 image with native window creation, DirectDraw/DirectSound init, registry-backed config under `Software\Electronic Arts\Crusader: No Remorse\J1.21`, IME/DBCS-facing imports, and a `GetVersion`-driven Win9x compatibility branch that retries `TlsAlloc()` until the slot is above `2` when the classic Win9x version bit is set. Current best read is `real Windows 9x-native port with likely Win95 intent`, with runtime prerequisites still left to test.
- The removed-item lane is tighter now too. `docs/removed_items.md` now records a live `CRUSADER.EXE` close on the inventory/display name path: retail `1118:056A` is `DTable_GetNameForShapeNo`, `1118:05D5` is its `INVALID` fallback returning `1478:238C`, and `Weasel_OnPaint` uses that same lookup family. The backing `1478:22BC` `char *[41]` array preserves exact explosive names inline, including `CONCUSSION GRENADE`, `NERVE GAS GRENADE`, `EMP GRENADE`, `SPIDER BOMB`, `LAND MINE`, `BLAST PAC`, and `FUSION PAC`, while repeating `INVALID` at slots `0/14/26/32`. The reusable Remorse dump at `out/dtable_get_name_dump.json` plus companion CSVs now closes the direct-table question too: `0548` is not a named dtable entry, so its in-game `Invalid` label is best explained as a plain fallback for an unmapped shape. The same tooling now also closes the Regret comparison side: live `REGRET.EXE` uses helper `1130:056a`, its recovered segment-`1480` dtable island expands the slot layout to `52` names with repeated `INVALID` at `0/17/36/44`, preserves Regret-only names such as `BK-16`, `LNR-81`, `XP-5`, `IONIC SHIELD`, `PLASMA SHIELD`, `RADIATION SHIELD`, and `VIR IMAGER`, and resolves bomb rows `0343`, `0350`, `0560`, `039A`, and `039C` while notably omitting `BLAST PAC`. Current best read remains narrower than the first pass: the removed grenade variants are real retail dtable names plus map leftovers, `LANDMINE`/`BLASTPAC`/`FUSPAC` are active Remorse classes rather than new removed items, and `SPIDER BOMB` is currently stronger as a cross-game dtable/comparison signal than as a fully closed Remorse finding.
- The Japanese localized-build lane now also covers surviving cheat/debug and startup-argument behavior. New note `docs/jp-remorse-cheats-and-launch-params.md` records that the JP Win32 build still has a live `-laurie` special-case, a live `JASSICA16` cheat-state matcher, a still-executable immortality toggle path, and a working Win32 parser for `-debug`, `-u`, `-warp`, `-skill`, `-mapoff`, `-egg`, and `-demo`. The same pass also adds one important caveat relative to the older DOS-side docs: the JP Win32 parser is only directly closed for mission-only `-warp <mission>` so far, not for positional `-warp <mission> <x> <y> <z>`. - The Japanese localized-build lane now also covers surviving cheat/debug and startup-argument behavior. New note `docs/jp-remorse-cheats-and-launch-params.md` records that the JP Win32 build still has a live `-laurie` special-case, a live `JASSICA16` cheat-state matcher, a still-executable immortality toggle path, and a working Win32 parser for `-debug`, `-u`, `-warp`, `-skill`, `-mapoff`, `-egg`, and `-demo`. The same pass also adds one important caveat relative to the older DOS-side docs: the JP Win32 parser is only directly closed for mission-only `-warp <mission>` so far, not for positional `-warp <mission> <x> <y> <z>`.
- The startup map-selection lane is now tighter across both retail games too: No Remorse still hardcodes `Teleporter_CreateProcessDirect(1, 0x1e, 1)` inside `Game_Start`, while No Regret keeps the same literal selector in two live places, the early `Game_Start` site at `1008:1448` and the later authoritative new-game hop in `Game_RunNewGameFlow` at `1030:05c5`. The separate `-warp mission` path still uses an executable-embedded word table plus `-mapoff`, and the repo docs now include the dedicated REGRET-side note `docs/regret-game-start.md`. Current best read remains `startup map choice in code, map contents in external FIXED.DAT resources`, not `mission-start map configured in CRUSADER.CFG`. - The startup map-selection lane is now tighter across both retail games too: No Remorse still hardcodes `Teleporter_CreateProcessDirect(1, 0x1e, 1)` inside `Game_Start`, while No Regret keeps the same literal selector in two live places, the early `Game_Start` site at `1008:1448` and the later authoritative new-game hop in `Game_RunNewGameFlow` at `1030:05c5`. The separate `-warp mission` path still uses an executable-embedded word table plus `-mapoff`, and the repo docs now include the dedicated REGRET-side note `docs/regret-game-start.md`. Current best read remains `startup map choice in code, map contents in external FIXED.DAT resources`, not `mission-start map configured in CRUSADER.CFG`.
- That same warp-table lane is now exact across both retail DOS executables too. Byte checks against `CRUSADER.EXE` and `REGRET.EXE` now show matching 17-word `-warp mission` base-map tables (`0,1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,40`) at `1478:0488` and `1480:075c`, each followed by a `0,0` terminator. The public map renderer now also has a dedicated mission-table extractor and generated JSON cache, so scene metadata no longer has to treat mission/base-map usage as an unknown ownership question.
- That same startup lane is now tighter at the argument level too. Current best parser/control-flow read in `REGRET.EXE` is `-warp <mission> [x y z]`, with X/Y/Z carried as positional argv tokens after the mission number rather than as separate recovered switches. The corresponding runtime branch in `Game_RunNewGameFlow` is also clearer: nonnegative `-egg` overrides beat the coordinate path, while the real eggless-map workaround is `-warp <mission> <x> <y> <z>` plus `-mapoff` with `-egg` omitted so the game falls into direct `NPC_Teleport` instead of the teleporter-egg lookup. - That same startup lane is now tighter at the argument level too. Current best parser/control-flow read in `REGRET.EXE` is `-warp <mission> [x y z]`, with X/Y/Z carried as positional argv tokens after the mission number rather than as separate recovered switches. The corresponding runtime branch in `Game_RunNewGameFlow` is also clearer: nonnegative `-egg` overrides beat the coordinate path, while the real eggless-map workaround is `-warp <mission> <x> <y> <z>` plus `-mapoff` with `-egg` omitted so the game falls into direct `NPC_Teleport` instead of the teleporter-egg lookup.
- The matching No Remorse cross-check is now closed too. Live `CRUSADER.EXE` `HandleCommandlineArgs` at `1048:0adc` uses the same positional `-warp <mission> [x y z]` parser shape, and `Game_Start` at `1020:029e` / `1020:02d0` uses the same runtime precedence: direct coordinates only win when the egg override is still negative, otherwise the code falls back to `Teleporter_CreateProcessDirect`. The parameter-only eggless-map workaround is therefore shared across both retail games, not Regret-specific. - The matching No Remorse cross-check is now closed too. Live `CRUSADER.EXE` `HandleCommandlineArgs` at `1048:0adc` uses the same positional `-warp <mission> [x y z]` parser shape, and `Game_Start` at `1020:029e` / `1020:02d0` uses the same runtime precedence: direct coordinates only win when the egg override is still negative, otherwise the code falls back to `Teleporter_CreateProcessDirect`. The parameter-only eggless-map workaround is therefore shared across both retail games, not Regret-specific.
- The command-line lane is tighter around `-u` now too. In live non-Japanese `CRUSADER.EXE`, the parser case at `1048:0a46` copies the following token into `1478:065a`, and the renamed `startup_apply_u_override_if_present` at `1420:0cdf` later consumes that buffer to resolve/load an alternate usecode/EUSECODE source into `1478:6611/6613`, mark `1478:6615`, and rebuild the cumulative slot-base words at `1478:8c7c..8c82`. Current best read is `real retail startup usecode override`, not `JP-only` and not `dead string-table residue`; the paired consequence is that the older CRUSADER-side `-setver` attribution should now be treated as reopened until its exact retail consumer is isolated directly. - The command-line lane is tighter around `-u` now too. In live non-Japanese `CRUSADER.EXE`, the parser case at `1048:0a46` copies the following token into `1478:065a`, and the renamed `startup_apply_u_override_if_present` at `1420:0cdf` later consumes that buffer to resolve/load an alternate usecode/EUSECODE source into `1478:6611/6613`, mark `1478:6615`, and rebuild the cumulative slot-base words at `1478:8c7c..8c82`. Current best read is `real retail startup usecode override`, not `JP-only` and not `dead string-table residue`; the paired consequence is that the older CRUSADER-side `-setver` attribution should now be treated as reopened until its exact retail consumer is isolated directly.
@ -188,16 +194,17 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan
7. Revisit the `0x4588` callback object only when caller-side evidence is strong enough to support behavioral naming. 7. Revisit the `0x4588` callback object only when caller-side evidence is strong enough to support behavioral naming.
8. Exercise `tools/render_crusader_map.py` on a few representative No Remorse and No Regret maps, then tighten the paint order using `TYPEFLAG.DAT` footpads and any mismatches visible against in-game screenshots or `crusader-disasm` map evidence. 8. Exercise `tools/render_crusader_map.py` on a few representative No Remorse and No Regret maps, then tighten the paint order using `TYPEFLAG.DAT` footpads and any mismatches visible against in-game screenshots or `crusader-disasm` map evidence.
9. If the map/editor-visibility lane is revisited, start from `docs/editor-object-visibility.md` and the upstream `1180:0951..095c` world-item builder gate first; rule in or rule out a second debug-only world-item builder before spending more time on cheat or command-line searches. 9. If the map/editor-visibility lane is revisited, start from `docs/editor-object-visibility.md` and the upstream `1180:0951..095c` world-item builder gate first; rule in or rule out a second debug-only world-item builder before spending more time on cheat or command-line searches.
10. Continue the PSX pre-alpha lane from `docs/psx/prealpha.md`: classify the surviving `LoadExec` callers around `80046aac`, confirm whether the `TALK1.XA` path is still reachable in practice, and compare the three shipped `LSET1` bundles against the retail extractor outputs before assuming the build is only a content-pruned No Remorse branch.
10. Recover the real upstream caller/selector path into `entity_vm_opcode_sequence_run`, most likely by finding the first non-recursive `0x6714` context-method caller or vtable dispatch site rather than by repeating raw xref queries that still return no direct edges. 11. Recover the real upstream caller/selector path into `entity_vm_opcode_sequence_run`, most likely by finding the first non-recursive `0x6714` context-method caller or vtable dispatch site rather than by repeating raw xref queries that still return no direct edges.
11. Recover real caller roles for `entity_vm_context_try_create_mask_0400_slot0a_with_offset` and `entity_vm_context_try_create_mask_0800_slot0b_with_offset` by treating them as the remaining dark members of the now-verified signed-additive masked-materializer subfamily and comparing them against the newly anchored slot-`0x12` caller pattern. 12. Recover real caller roles for `entity_vm_context_try_create_mask_0400_slot0a_with_offset` and `entity_vm_context_try_create_mask_0800_slot0b_with_offset` by treating them as the remaining dark members of the now-verified signed-additive masked-materializer subfamily and comparing them against the newly anchored slot-`0x12` caller pattern.
12. Tighten the newly surfaced higher-slot wrapper ladder around `0005:3115..31da`, especially the two slot-`0x12` caller sites at `0005:1776` / `0005:1945` and the slot-`0x10` guarded callsite, so any future promotion to `leaveFastArea` / `func11|cast` / `justMoved` / `AvatarStoleSomething` / `animGetHit` is driven by binary caller behavior rather than by external tables alone. 13. Tighten the newly surfaced higher-slot wrapper ladder around `0005:3115..31da`, especially the two slot-`0x12` caller sites at `0005:1776` / `0005:1945` and the slot-`0x10` guarded callsite, so any future promotion to `leaveFastArea` / `func11|cast` / `justMoved` / `AvatarStoleSomething` / `animGetHit` is driven by binary caller behavior rather than by external tables alone.
13. Tighten the outward caller chains around the renamed seg006 masked helpers `entity_vm_context_try_create_mask_0008_slot30_with_offset` (`0006:0ba4`) and `entity_vm_context_try_create_mask_0010_slot08_with_offset_if_ready` (`0006:108c`) so the local state-selector lane and the adjacent class-linked value family can be tied back to concrete gameplay subsystems rather than only to class-detail fields. 14. Tighten the outward caller chains around the renamed seg006 masked helpers `entity_vm_context_try_create_mask_0008_slot30_with_offset` (`0006:0ba4`) and `entity_vm_context_try_create_mask_0010_slot08_with_offset_if_ready` (`0006:108c`) so the local state-selector lane and the adjacent class-linked value family can be tied back to concrete gameplay subsystems rather than only to class-detail fields.
14. Tighten the paired-file-family reading of the seg070 twin loops at `0009:67b6` and `0009:6916` by recovering which temporary buffer and record schema each family populates behind `entity_vm_runtime_owner_resource_create`. 15. Tighten the paired-file-family reading of the seg070 twin loops at `0009:67b6` and `0009:6916` by recovering which temporary buffer and record schema each family populates behind `entity_vm_runtime_owner_resource_create`.
15. Promote additional ledger rows where the current docs already justify `Foothold`, `Partial`, or `Deep`. 16. Promote additional ledger rows where the current docs already justify `Foothold`, `Partial`, or `Deep`.
16. If the VM lane stalls again, revisit `000e:ffb0` from the now-verified `00db/00dc` caller windows and try to recover an adjacent non-overlapped helper before attempting any boundary repair. 17. If the VM lane stalls again, revisit `000e:ffb0` from the now-verified `00db/00dc` caller windows and try to recover an adjacent non-overlapped helper before attempting any boundary repair.
17. If the immortality lane is revisited, stay focused on `NPCTRIG` slot `0x0a` first, with slot `0x20` still treated as the typed/setup companion and `EVENT` only as the generic hub baseline; the three currently recovered direct `0005:295f` caller families are now all closed and comment-backed in the live NE program at `10f0:02d9`, `10f0:0379`, `10f0:03c3`, `10f0:03e5`, `1128:0ff0`, and `1138:1384`, so the next defensible step is an earlier producer that assigns subtype `0x20b/0x20c` into field `+0x3c` or otherwise chooses the owner-loaded class family before these generic damage consumers run. 18. If the immortality lane is revisited, stay focused on `NPCTRIG` slot `0x0a` first, with slot `0x20` still treated as the typed/setup companion and `EVENT` only as the generic hub baseline; the three currently recovered direct `0005:295f` caller families are now all closed and comment-backed in the live NE program at `10f0:02d9`, `10f0:0379`, `10f0:03c3`, `10f0:03e5`, `1128:0ff0`, and `1138:1384`, so the next defensible step is an earlier producer that assigns subtype `0x20b/0x20c` into field `+0x3c` or otherwise chooses the owner-loaded class family before these generic damage consumers run.
18. Use the new Pentagram-derived parser proof of concept as the first tooling bridge for raw class/slot bodies: extend opcode coverage conservatively, emit IR v1 artifacts, and only then prototype a Ghidra-side annotation importer against compiled anchors like `000d:51fd`, `000d:5572`, `000d:46ec`, `000d:22bc`, and `000d:ebe3`. 19. Use the new Pentagram-derived parser proof of concept as the first tooling bridge for raw class/slot bodies: extend opcode coverage conservatively, emit IR v1 artifacts, and only then prototype a Ghidra-side annotation importer against compiled anchors like `000d:51fd`, `000d:5572`, `000d:46ec`, `000d:22bc`, and `000d:ebe3`.
## Remaining Work To Reach A Reasonably Complete Decompilation State ## Remaining Work To Reach A Reasonably Complete Decompilation State

1790
stdout Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import re
from pathlib import Path
path = Path(r"E:\\emu\\psx\\Crusader - No Remorse\\SLUS_002.68")
data = path.read_bytes()
pattern = re.compile(rb"[ -~]{4,}")
needle = re.compile(r"WDL|LSET|MENU|SPEC|MAP|SPR|TILE", re.I)
seen = set()
for m in pattern.finditer(data):
s = m.group().decode("ascii", errors="ignore")
if needle.search(s) and s not in seen:
seen.add(s)
print(f"0x{m.start():08X}\t{s}")

458
tools/dump_dtable_names.py Normal file
View file

@ -0,0 +1,458 @@
from __future__ import annotations
import argparse
import csv
import json
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class CategorySpec:
name: str
table_offset: int
entry_size: int
entry_count: int
display_frame_offset: int
display_frame_size: int
name_base_global_index: int
helper_name: str
@dataclass(frozen=True)
class TargetProfile:
target: str
exe_name: str
xml_name: str | None
data_segment: str
helper_function_address: str
data_segment_file_offset: int
pointer_table_offset: int
global_slot_count: int
string_pool_offset: int
sequential_string_count: int | None
invalid_alias_indices: frozenset[int]
output_prefix: str
categories: tuple[CategorySpec, ...]
notes: tuple[str, ...]
REMORSE_CATEGORIES: tuple[CategorySpec, ...] = (
CategorySpec(
name="weapon",
table_offset=0x20C6,
entry_size=0x12,
entry_count=14,
display_frame_offset=0x0D,
display_frame_size=1,
name_base_global_index=0,
helper_name="Weapon_GetNameForShapeNo",
),
CategorySpec(
name="misc_item",
table_offset=0x2244,
entry_size=0x04,
entry_count=12,
display_frame_offset=0x02,
display_frame_size=2,
name_base_global_index=14,
helper_name="MiscTable_GetNameForItemShapeNo",
),
CategorySpec(
name="bomb",
table_offset=0x2274,
entry_size=0x08,
entry_count=9,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=32,
helper_name="MiscTable_GetNameForBombShapeNo",
),
CategorySpec(
name="ammo",
table_offset=0x2042,
entry_size=0x08,
entry_count=6,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=26,
helper_name="Ammo_GetNameForShapeNo",
),
)
REGRET_CATEGORIES: tuple[CategorySpec, ...] = (
CategorySpec(
name="weapon",
table_offset=0x2614,
entry_size=0x12,
entry_count=17,
display_frame_offset=0x0D,
display_frame_size=1,
name_base_global_index=0,
helper_name="Weapon_GetNameForShapeNo",
),
CategorySpec(
name="misc_item",
table_offset=0x2792,
entry_size=0x04,
entry_count=19,
display_frame_offset=0x02,
display_frame_size=2,
name_base_global_index=17,
helper_name="MiscTable_GetNameForItemShapeNo",
),
CategorySpec(
name="bomb",
table_offset=0x27DE,
entry_size=0x08,
entry_count=8,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=44,
helper_name="MiscTable_GetNameForBombShapeNo",
),
CategorySpec(
name="ammo",
table_offset=0x2590,
entry_size=0x08,
entry_count=8,
display_frame_offset=0x06,
display_frame_size=2,
name_base_global_index=36,
helper_name="Ammo_GetNameForShapeNo",
),
)
PROFILES: dict[str, TargetProfile] = {
"remorse": TargetProfile(
target="remorse",
exe_name="CRUSADER.EXE",
xml_name="CRUSADER.EXE.xml",
data_segment="1478",
helper_function_address="1118:056a",
data_segment_file_offset=0xE3C00,
pointer_table_offset=0x22BC,
global_slot_count=41,
string_pool_offset=0x238C,
sequential_string_count=None,
invalid_alias_indices=frozenset({0, 14, 26, 32}),
output_prefix="",
categories=REMORSE_CATEGORIES,
notes=(
"Each category uses local index 0 as a reserved INVALID slot.",
"DTable_GetNameForShapeNo tests categories in order: weapon, misc_item, bomb, ammo, then falls back to INVALID.",
"The dump is derived from live retail CRUSADER.EXE bytes plus the named helper structure recovered in segment 1118.",
),
),
"regret": TargetProfile(
target="regret",
exe_name="REGRET.EXE",
xml_name=None,
data_segment="1480",
helper_function_address="1130:056a",
data_segment_file_offset=0xE2400,
pointer_table_offset=0x2856,
global_slot_count=52,
string_pool_offset=0x2926,
sequential_string_count=49,
invalid_alias_indices=frozenset({0, 17, 36, 44}),
output_prefix="regret_",
categories=REGRET_CATEGORIES,
notes=(
"Each category uses local index 0 as a reserved INVALID slot, but Regret stores only one physical INVALID string and aliases it into later category starts.",
"DTable_GetNameForShapeNo is recovered in the live REGRET.EXE helper cluster at 1130:056a, alongside the same 1130:0145..0474 name-helper family seen in Remorse.",
"This dump is derived from live retail REGRET.EXE bytes. No local REGRET.EXE XML export was required once the 1480 data-segment base, pointer table, and string pool were recovered from the binary.",
),
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Dump Crusader DTable_GetNameForShapeNo mappings.")
parser.add_argument("target", nargs="?", default="remorse", choices=sorted(PROFILES.keys()))
return parser.parse_args()
def read_u16(data: bytes, start: int) -> int:
return int.from_bytes(data[start : start + 2], "little")
def read_u8(data: bytes, start: int) -> int:
return data[start]
def read_cstring(data: bytes, start: int) -> str:
end = data.index(0, start)
return data[start:end].decode("ascii")
def segment_abs(profile: TargetProfile, offset: int) -> str:
return f"{profile.data_segment}:{offset:04x}"
def load_global_names_from_xml(profile: TargetProfile, segment_data: bytes, xml_text: str) -> list[dict[str, object]]:
relocations: dict[int, int] = {}
pattern = re.compile(
rf'<RELOCATION ADDRESS="{re.escape(profile.data_segment)}:([0-9a-f]{{4}})" TYPE="0x3" VALUE="0x[0-9a-f]+,0x0,0x[0-9a-f]{{4}},0x[0-9a-f]+,0x([0-9a-f]{{4}})"',
re.IGNORECASE,
)
for match in pattern.finditer(xml_text):
slot_offset = int(match.group(1), 16)
target_offset = int(match.group(2), 16)
relocations[slot_offset] = target_offset
names: list[dict[str, object]] = []
for global_index in range(profile.global_slot_count):
slot_offset = profile.pointer_table_offset + (global_index * 4)
target_offset = relocations[slot_offset]
text = read_cstring(segment_data, target_offset)
names.append(
{
"global_index": global_index,
"slot_address": segment_abs(profile, slot_offset),
"address": segment_abs(profile, target_offset),
"name": text,
}
)
return names
def load_global_names_from_string_pool(profile: TargetProfile, segment_data: bytes) -> list[dict[str, object]]:
if profile.sequential_string_count is None:
raise ValueError(f"Profile {profile.target} does not define a sequential string count")
unique_strings: list[tuple[int, str]] = []
cursor = profile.string_pool_offset
for _ in range(profile.sequential_string_count):
text = read_cstring(segment_data, cursor)
unique_strings.append((cursor, text))
cursor += len(text) + 1
invalid_offset, invalid_name = unique_strings[0]
next_unique_index = 1
names: list[dict[str, object]] = []
for global_index in range(profile.global_slot_count):
slot_offset = profile.pointer_table_offset + (global_index * 4)
if global_index in profile.invalid_alias_indices:
target_offset = invalid_offset
text = invalid_name
else:
target_offset, text = unique_strings[next_unique_index]
next_unique_index += 1
names.append(
{
"global_index": global_index,
"slot_address": segment_abs(profile, slot_offset),
"address": segment_abs(profile, target_offset),
"name": text,
}
)
if next_unique_index != len(unique_strings):
raise ValueError(
f"Profile {profile.target} consumed {next_unique_index} strings but extracted {len(unique_strings)} unique strings"
)
return names
def load_global_names(profile: TargetProfile, repo_root: Path, segment_data: bytes) -> list[dict[str, object]]:
if profile.xml_name:
xml_path = repo_root / "exports" / profile.xml_name
xml_text = xml_path.read_text(encoding="utf-8")
return load_global_names_from_xml(profile, segment_data, xml_text)
return load_global_names_from_string_pool(profile, segment_data)
def load_category_entries(
profile: TargetProfile,
segment_data: bytes,
global_names: list[dict[str, object]],
) -> dict[str, list[dict[str, object]]]:
category_entries: dict[str, list[dict[str, object]]] = {}
for spec in profile.categories:
entries: list[dict[str, object]] = []
for local_index in range(spec.entry_count):
entry_offset = spec.table_offset + (local_index * spec.entry_size)
shape = read_u16(segment_data, entry_offset)
if spec.display_frame_size == 1:
display_frame = read_u8(segment_data, entry_offset + spec.display_frame_offset)
else:
display_frame = read_u16(segment_data, entry_offset + spec.display_frame_offset)
global_name_index = spec.name_base_global_index + local_index
name_entry = global_names[global_name_index]
entries.append(
{
"category": spec.name,
"helper_name": spec.helper_name,
"local_index": local_index,
"reserved_zero_slot": local_index == 0,
"table_address": segment_abs(profile, entry_offset),
"shape": shape,
"shape_hex": f"0x{shape:04X}",
"display_frame": display_frame,
"display_frame_hex": f"0x{display_frame:04X}",
"global_name_index": global_name_index,
"global_name_slot_address": name_entry["slot_address"],
"global_name_address": name_entry["address"],
"name": name_entry["name"],
}
)
category_entries[spec.name] = entries
return category_entries
def resolve_shapes(
profile: TargetProfile,
category_entries: dict[str, list[dict[str, object]]],
fallback_name: str,
) -> list[dict[str, object]]:
shape_membership: dict[int, list[dict[str, object]]] = {}
for spec in profile.categories:
for entry in category_entries[spec.name]:
shape_membership.setdefault(int(entry["shape"]), []).append(entry)
resolved: list[dict[str, object]] = []
for shape in sorted(shape_membership):
if shape == 0:
continue
memberships = shape_membership[shape]
chosen: dict[str, object] | None = None
for spec in profile.categories:
for entry in memberships:
if entry["category"] == spec.name and not entry["reserved_zero_slot"] and int(entry["shape"]) != 0:
chosen = entry
break
if chosen is not None:
break
resolved.append(
{
"shape": shape,
"shape_hex": f"0x{shape:04X}",
"resolved_name": chosen["name"] if chosen is not None else fallback_name,
"resolved_category": chosen["category"] if chosen is not None else "fallback_invalid",
"resolved_helper_name": chosen["helper_name"] if chosen is not None else "DTable_GetNameForShapeNo fallback",
"resolved_local_index": chosen["local_index"] if chosen is not None else None,
"resolved_global_name_index": chosen["global_name_index"] if chosen is not None else 0,
"resolved_global_name_slot_address": chosen["global_name_slot_address"] if chosen is not None else segment_abs(profile, profile.pointer_table_offset),
"resolved_global_name_address": chosen["global_name_address"] if chosen is not None else segment_abs(profile, profile.string_pool_offset),
"display_frame": chosen["display_frame"] if chosen is not None else None,
"display_frame_hex": chosen["display_frame_hex"] if chosen is not None else None,
"memberships": memberships,
}
)
return resolved
def write_slot_csv(path: Path, global_names: list[dict[str, object]]) -> None:
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["global_index", "slot_address", "address", "name"])
writer.writeheader()
for row in global_names:
writer.writerow(row)
def write_table_csv(path: Path, categories: tuple[CategorySpec, ...], category_entries: dict[str, list[dict[str, object]]]) -> None:
fieldnames = [
"category",
"helper_name",
"local_index",
"reserved_zero_slot",
"table_address",
"shape",
"shape_hex",
"display_frame",
"display_frame_hex",
"global_name_index",
"global_name_slot_address",
"global_name_address",
"name",
]
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for category in (spec.name for spec in categories):
for row in category_entries[category]:
writer.writerow({key: row[key] for key in fieldnames})
def write_resolved_csv(path: Path, resolved_rows: list[dict[str, object]]) -> None:
fieldnames = [
"shape",
"shape_hex",
"resolved_name",
"resolved_category",
"resolved_helper_name",
"resolved_local_index",
"resolved_global_name_index",
"resolved_global_name_slot_address",
"resolved_global_name_address",
"display_frame",
"display_frame_hex",
]
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in resolved_rows:
writer.writerow({key: row[key] for key in fieldnames})
def output_basename(profile: TargetProfile) -> str:
return f"{profile.output_prefix}dtable"
def main() -> None:
args = parse_args()
profile = PROFILES[args.target]
repo_root = Path(__file__).resolve().parent.parent
exe_path = repo_root / profile.exe_name
out_dir = repo_root / "out"
out_dir.mkdir(parents=True, exist_ok=True)
exe_bytes = exe_path.read_bytes()
segment_data = exe_bytes[profile.data_segment_file_offset :]
global_names = load_global_names(profile, repo_root, segment_data)
category_entries = load_category_entries(profile, segment_data, global_names)
resolved_rows = resolve_shapes(profile, category_entries, fallback_name=str(global_names[0]["name"]))
json_path = out_dir / f"{output_basename(profile)}_get_name_dump.json"
payload = {
"target": profile.target,
"function": "DTable_GetNameForShapeNo",
"function_address": profile.helper_function_address,
"segment": profile.data_segment,
"segment_file_offset_hex": f"0x{profile.data_segment_file_offset:05X}",
"pointer_table_address": segment_abs(profile, profile.pointer_table_offset),
"string_pool_address": segment_abs(profile, profile.string_pool_offset),
"lookup_order": [spec.name for spec in profile.categories],
"global_name_slots": global_names,
"category_entries": category_entries,
"resolved_shapes": resolved_rows,
"fallback_name": global_names[0]["name"],
"fallback_global_index": global_names[0]["global_index"],
"notes": list(profile.notes),
}
json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
slots_csv_path = out_dir / f"{output_basename(profile)}_global_name_slots.csv"
tables_csv_path = out_dir / f"{output_basename(profile)}_category_entries.csv"
resolved_csv_path = out_dir / f"{output_basename(profile)}_resolved_shapes.csv"
write_slot_csv(slots_csv_path, global_names)
write_table_csv(tables_csv_path, profile.categories, category_entries)
write_resolved_csv(resolved_csv_path, resolved_rows)
print(f"Wrote {json_path}")
print(f"Wrote {slots_csv_path}")
print(f"Wrote {tables_csv_path}")
print(f"Wrote {resolved_csv_path}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,254 @@
from __future__ import annotations
import argparse
import struct
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(r"k:/ghidra/Crusader_Decomp")
DEFAULT_DISC_ROOT = Path(r"e:/emu/psx/Crusader - No Remorse")
DEFAULT_GPU_DUMP = ROOT / "binary/Crusader - No Remorse (USA) GPU RAM.bin"
DEFAULT_OUTPUT = ROOT / "out/psx_wdl/all_lset_sprite_categories"
ROW_BYTES = 2048
LIVE_CLUT_Y = 0xF0
LIVE_CLUT_X = 0
ATLAS_MAX_WIDTH = 1024
ATLAS_MAX_HEIGHT = 1024
ATLAS_PADDING = 4
sys.path.insert(0, str(ROOT / "tools"))
from psx_extract_wdl import (
choose_palette,
colorize_indexed_pixels,
extract_palette_sets,
parse_lset_wdl,
scan_sprite_bundles,
write_png_rgba,
)
@dataclass
class SpriteFrame:
source_tag: str
source_name: str
bundle_offset: int
absolute_offset: int
frame_index: int
width: int
height: int
mode: int
frame_count: int
rgba: bytes
opaque_pixels: int
@property
def area(self) -> int:
return self.width * self.height
@property
def opaque_ratio(self) -> float:
if self.area == 0:
return 0.0
return self.opaque_pixels / self.area
@property
def aspect_ratio(self) -> float:
if self.height == 0:
return 0.0
return self.width / self.height
@property
def stem(self) -> str:
return (
f"{self.source_tag}_off_{self.absolute_offset:08X}_bundle_{self.bundle_offset:08X}"
f"_frame_{self.frame_index:03d}_m{self.mode}"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Dump all LSET sprite bundles from the PSX game files into category folders and packed atlases."
)
parser.add_argument("--disc-root", type=Path, default=DEFAULT_DISC_ROOT)
parser.add_argument("--gpu-dump", type=Path, default=DEFAULT_GPU_DUMP)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--max-candidates", type=int, default=0)
return parser.parse_args()
def load_live_palette(gpu_dump_path: Path) -> list[int]:
gpu = gpu_dump_path.read_bytes()
row = gpu[LIVE_CLUT_Y * ROW_BYTES : (LIVE_CLUT_Y + 1) * ROW_BYTES]
row_words = struct.unpack("<1024H", row)
return list(row_words[LIVE_CLUT_X : LIVE_CLUT_X + 256])
def count_opaque_pixels(rgba: bytes) -> int:
opaque = 0
for offset in range(3, len(rgba), 4):
if rgba[offset] != 0:
opaque += 1
return opaque
def classify_frame(frame: SpriteFrame) -> str:
if frame.opaque_pixels <= 96 or (frame.area <= 512 and frame.opaque_ratio < 0.22):
return "effects_and_particles"
if frame.width >= 72 or frame.height >= 72 or frame.area >= 3200:
return "large_props"
if frame.aspect_ratio >= 1.85 and frame.height <= 32:
return "panels_and_strips"
if frame.aspect_ratio <= 0.55 and frame.height >= 28:
return "tall_fixtures"
if frame.height <= 18 and frame.width <= 48:
return "pickups_and_weapons"
if frame.height <= 28 and frame.width <= 40 and frame.area <= 900:
return "small_items"
if frame.frame_count >= 4 and frame.area <= 1600:
return "animated_small_props"
return "medium_props"
def pack_pages(frames: list[SpriteFrame]) -> list[list[tuple[SpriteFrame, int, int]]]:
sorted_frames = sorted(frames, key=lambda item: (item.height, item.width, item.area), reverse=True)
pages: list[list[tuple[SpriteFrame, int, int]]] = []
current_page: list[tuple[SpriteFrame, int, int]] = []
cursor_x = 0
cursor_y = 0
shelf_height = 0
for frame in sorted_frames:
needed_width = frame.width + (ATLAS_PADDING if cursor_x else 0)
if cursor_x and cursor_x + needed_width > ATLAS_MAX_WIDTH:
cursor_x = 0
cursor_y += shelf_height + ATLAS_PADDING
shelf_height = 0
if cursor_y and cursor_y + frame.height > ATLAS_MAX_HEIGHT:
pages.append(current_page)
current_page = []
cursor_x = 0
cursor_y = 0
shelf_height = 0
place_x = cursor_x + (ATLAS_PADDING if cursor_x else 0)
current_page.append((frame, place_x, cursor_y))
cursor_x = place_x + frame.width
shelf_height = max(shelf_height, frame.height)
if current_page:
pages.append(current_page)
return pages
def write_packed_atlas(path: Path, placements: list[tuple[SpriteFrame, int, int]]) -> None:
atlas_width = max(x + frame.width for frame, x, _ in placements)
atlas_height = max(y + frame.height for frame, _, y in placements)
atlas = bytearray(atlas_width * atlas_height * 4)
for frame, origin_x, origin_y in placements:
for y in range(frame.height):
src_start = y * frame.width * 4
dst_start = ((origin_y + y) * atlas_width + origin_x) * 4
atlas[dst_start : dst_start + frame.width * 4] = frame.rgba[src_start : src_start + frame.width * 4]
write_png_rgba(path, bytes(atlas), atlas_width, atlas_height)
def export_category(output_dir: Path, category: str, frames: list[SpriteFrame]) -> None:
category_dir = output_dir / category
category_dir.mkdir(parents=True, exist_ok=True)
for frame in frames:
write_png_rgba(category_dir / f"{frame.stem}.png", frame.rgba, frame.width, frame.height)
for page_index, placements in enumerate(pack_pages(frames)):
write_packed_atlas(category_dir / f"atlas_{page_index:02d}.png", placements)
def source_tag_from_path(disc_root: Path, path: Path) -> str:
relative = path.relative_to(disc_root)
parts = [part.lower().replace(".wdl", "") for part in relative.parts]
return "_".join(parts)
def collect_frames_from_lset(
disc_root: Path,
path: Path,
live_palette: list[int],
max_candidates: int,
) -> list[SpriteFrame]:
data = path.read_bytes()
summary = parse_lset_wdl(data)
if summary is None:
return []
graphics_region = next(
(region for region in summary["regions"] if region["name"] == "post_audio_region_04"),
None,
)
if graphics_region is None:
return []
region_data = data[graphics_region["offset"] : graphics_region["offset"] + graphics_region["size"]]
palettes_16 = extract_palette_sets(data, summary)
source_tag = source_tag_from_path(disc_root, path)
limit = None if max_candidates <= 0 else max_candidates
frames: list[SpriteFrame] = []
for bundle in scan_sprite_bundles(region_data, max_candidates=limit):
if bundle["mode"] == 1:
palette = live_palette
elif bundle["mode"] == 2:
palette_index = bundle.get("palette_index")
if palette_index is None or palette_index >= len(palettes_16):
palette_index = choose_palette(palettes_16, bundle["frames"], bundle["mode"])
if palette_index is None or palette_index >= len(palettes_16):
continue
palette = palettes_16[palette_index]
else:
continue
absolute_bundle_offset = graphics_region["offset"] + bundle["offset"]
for frame in bundle["frames"]:
rgba = colorize_indexed_pixels(frame["pixels"], frame["width"], frame["height"], bundle["mode"], palette)
frames.append(
SpriteFrame(
source_tag=source_tag,
source_name=str(path.relative_to(disc_root)),
bundle_offset=bundle["offset"],
absolute_offset=absolute_bundle_offset,
frame_index=frame["index"],
width=frame["width"],
height=frame["height"],
mode=bundle["mode"],
frame_count=bundle["frame_count"],
rgba=rgba,
opaque_pixels=count_opaque_pixels(rgba),
)
)
return frames
def main() -> None:
args = parse_args()
args.output.mkdir(parents=True, exist_ok=True)
live_palette = load_live_palette(args.gpu_dump)
wdl_paths = sorted(args.disc_root.glob("LSET*/L*.WDL"))
all_frames: list[SpriteFrame] = []
for path in wdl_paths:
all_frames.extend(collect_frames_from_lset(args.disc_root, path, live_palette, args.max_candidates))
categories: dict[str, list[SpriteFrame]] = {}
for frame in all_frames:
categories.setdefault(classify_frame(frame), []).append(frame)
for category, frames in sorted(categories.items()):
export_category(args.output, category, frames)
print(f"source_files={len(wdl_paths)}")
print(f"frames={len(all_frames)}")
for category, frames in sorted(categories.items()):
print(f"{category}={len(frames)}")
print(f"folder={args.output / category}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,202 @@
from __future__ import annotations
import argparse
import struct
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(r"k:/ghidra/Crusader_Decomp")
DEFAULT_L0_WDL = Path(r"e:/emu/psx/Crusader - No Remorse/LSET1/L0.WDL")
DEFAULT_GPU_DUMP = ROOT / "binary/Crusader - No Remorse (USA) GPU RAM.bin"
DEFAULT_OUTPUT = ROOT / "out/psx_wdl/L0/mode1_live_clut_categories"
ROW_BYTES = 2048
LIVE_CLUT_Y = 0xF0
LIVE_CLUT_X = 0
ATLAS_MAX_WIDTH = 1024
ATLAS_MAX_HEIGHT = 1024
ATLAS_PADDING = 4
import sys
sys.path.insert(0, str(ROOT / "tools"))
from psx_extract_wdl import colorize_indexed_pixels, parse_lset_wdl, scan_sprite_bundles, write_png_rgba
@dataclass
class SpriteFrame:
bundle_offset: int
frame_index: int
width: int
height: int
frame_count: int
rgba: bytes
opaque_pixels: int
@property
def area(self) -> int:
return self.width * self.height
@property
def opaque_ratio(self) -> float:
if self.area == 0:
return 0.0
return self.opaque_pixels / self.area
@property
def aspect_ratio(self) -> float:
if self.height == 0:
return 0.0
return self.width / self.height
@property
def stem(self) -> str:
return f"bundle_{self.bundle_offset:08X}_frame_{self.frame_index:03d}"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Export mode-1 PSX L0 sprites into category folders using the verified live CLUT row formula."
)
parser.add_argument("--wdl", type=Path, default=DEFAULT_L0_WDL)
parser.add_argument("--gpu-dump", type=Path, default=DEFAULT_GPU_DUMP)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--max-candidates", type=int, default=160)
return parser.parse_args()
def load_live_palette(gpu_dump_path: Path) -> list[int]:
gpu = gpu_dump_path.read_bytes()
row = gpu[LIVE_CLUT_Y * ROW_BYTES : (LIVE_CLUT_Y + 1) * ROW_BYTES]
row_words = struct.unpack("<1024H", row)
return list(row_words[LIVE_CLUT_X : LIVE_CLUT_X + 256])
def count_opaque_pixels(rgba: bytes) -> int:
opaque = 0
for offset in range(3, len(rgba), 4):
if rgba[offset] != 0:
opaque += 1
return opaque
def classify_frame(frame: SpriteFrame) -> str:
if frame.opaque_pixels <= 96 or (frame.area <= 512 and frame.opaque_ratio < 0.22):
return "effects_and_particles"
if frame.width >= 72 or frame.height >= 72 or frame.area >= 3200:
return "large_props"
if frame.aspect_ratio >= 1.85 and frame.height <= 32:
return "panels_and_strips"
if frame.aspect_ratio <= 0.55 and frame.height >= 28:
return "tall_fixtures"
if frame.height <= 18 and frame.width <= 48:
return "pickups_and_weapons"
if frame.height <= 28 and frame.width <= 40 and frame.area <= 900:
return "small_items"
if frame.frame_count >= 4 and frame.area <= 1600:
return "animated_small_props"
return "medium_props"
def pack_pages(frames: list[SpriteFrame]) -> list[list[tuple[SpriteFrame, int, int]]]:
sorted_frames = sorted(frames, key=lambda item: (item.height, item.width, item.area), reverse=True)
pages: list[list[tuple[SpriteFrame, int, int]]] = []
current_page: list[tuple[SpriteFrame, int, int]] = []
cursor_x = 0
cursor_y = 0
shelf_height = 0
for frame in sorted_frames:
needed_width = frame.width + (ATLAS_PADDING if cursor_x else 0)
if cursor_x and cursor_x + needed_width > ATLAS_MAX_WIDTH:
cursor_x = 0
cursor_y += shelf_height + ATLAS_PADDING
shelf_height = 0
if cursor_y and cursor_y + frame.height > ATLAS_MAX_HEIGHT:
pages.append(current_page)
current_page = []
cursor_x = 0
cursor_y = 0
shelf_height = 0
place_x = cursor_x + (ATLAS_PADDING if cursor_x else 0)
current_page.append((frame, place_x, cursor_y))
cursor_x = place_x + frame.width
shelf_height = max(shelf_height, frame.height)
if current_page:
pages.append(current_page)
return pages
def write_packed_atlas(path: Path, placements: list[tuple[SpriteFrame, int, int]]) -> None:
atlas_width = max(x + frame.width for frame, x, _ in placements)
atlas_height = max(y + frame.height for frame, _, y in placements)
atlas = bytearray(atlas_width * atlas_height * 4)
for frame, origin_x, origin_y in placements:
for y in range(frame.height):
src_start = y * frame.width * 4
dst_start = ((origin_y + y) * atlas_width + origin_x) * 4
atlas[dst_start : dst_start + frame.width * 4] = frame.rgba[src_start : src_start + frame.width * 4]
write_png_rgba(path, bytes(atlas), atlas_width, atlas_height)
def export_category(output_dir: Path, category: str, frames: list[SpriteFrame]) -> None:
category_dir = output_dir / category
category_dir.mkdir(parents=True, exist_ok=True)
for frame in frames:
write_png_rgba(category_dir / f"{frame.stem}.png", frame.rgba, frame.width, frame.height)
for page_index, placements in enumerate(pack_pages(frames)):
write_packed_atlas(category_dir / f"atlas_{page_index:02d}.png", placements)
def collect_mode1_frames(wdl_path: Path, gpu_dump_path: Path, max_candidates: int) -> list[SpriteFrame]:
l0_data = wdl_path.read_bytes()
palette = load_live_palette(gpu_dump_path)
summary = parse_lset_wdl(l0_data)
if summary is None:
raise SystemExit(f"failed to parse {wdl_path}")
region = next(region for region in summary["regions"] if region["name"] == "post_audio_region_04")
region_data = l0_data[region["offset"] : region["offset"] + region["size"]]
frames: list[SpriteFrame] = []
for bundle in scan_sprite_bundles(region_data, max_candidates=max_candidates):
if bundle["mode"] != 1:
continue
for frame in bundle["frames"]:
rgba = colorize_indexed_pixels(frame["pixels"], frame["width"], frame["height"], 1, palette)
frames.append(
SpriteFrame(
bundle_offset=bundle["offset"],
frame_index=frame["index"],
width=frame["width"],
height=frame["height"],
frame_count=bundle["frame_count"],
rgba=rgba,
opaque_pixels=count_opaque_pixels(rgba),
)
)
return frames
def main() -> None:
args = parse_args()
args.output.mkdir(parents=True, exist_ok=True)
frames = collect_mode1_frames(args.wdl, args.gpu_dump, args.max_candidates)
categories: dict[str, list[SpriteFrame]] = {}
for frame in frames:
categories.setdefault(classify_frame(frame), []).append(frame)
for category, category_frames in sorted(categories.items()):
export_category(args.output, category, category_frames)
print(f"frames={len(frames)}")
for category, category_frames in sorted(categories.items()):
print(f"{category}={len(category_frames)}")
print(f"folder={args.output / category}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,559 @@
from __future__ import annotations
import argparse
import json
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
DEFAULT_INPUT = Path(r"k:\ghidra\Crusader_Decomp\out\psx_wdl\L0\post_audio_region_01_00007448_paired_u16x6.json")
DEFAULT_SUMMARY = Path(r"k:\ghidra\Crusader_Decomp\out\psx_wdl\L0\summary.json")
DEFAULT_SPRITE_ROOT = Path(r"k:\ghidra\Crusader_Decomp\out\psx_wdl\L0\sprite_bundles")
DEFAULT_OUTPUT_ROOT = Path(r"k:\ghidra\Crusader_Decomp_Public\map_renderer\site\data")
DEFAULT_MAP_ID = 0
DEBUG_SCENE_VERSION = "psx-region01-unverified-bundle-probe-v2"
SCREEN_SCALE = 2
ALLOWED_U5 = {0x20, 0x22, 0x30}
@dataclass(frozen=True)
class PlacementRecord:
side: str
row_index: int
record_index: int
u0: int
u1: int
u2: int
u3: int
u4: int
u5: int
@dataclass(frozen=True)
class BundleFrame:
bundle_index: int
bundle_offset: int
frame_index: int
frame_count: int
width: int
height: int
origin_x: int
origin_y: int
palette_index: int
source_png: Path
def sanitize_origin(origin_x: int, origin_y: int, width: int, height: int) -> tuple[int, int]:
clean_x = origin_x
clean_y = origin_y
if clean_x < 0 or clean_x > width * 4:
clean_x = width // 2
if clean_y < 0 or clean_y > height * 4:
clean_y = max(0, height - 1)
return clean_x, clean_y
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Export a PSX LSET region-01 debug scene for the public map renderer.")
parser.add_argument("--input", type=Path, default=DEFAULT_INPUT, help="Path to paired_u16x6 JSON export")
parser.add_argument("--summary", type=Path, default=DEFAULT_SUMMARY, help="Path to LSET summary.json with sprite bundle metadata")
parser.add_argument("--sprite-root", type=Path, default=DEFAULT_SPRITE_ROOT, help="Path to extracted sprite_bundles directory")
parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT, help="Public renderer site/data root")
parser.add_argument("--game-id", default="psx-remorse", help="Catalog game id to write")
parser.add_argument("--game-label", default="No Remorse (PSX)", help="Catalog game label")
parser.add_argument("--map-id", type=int, default=DEFAULT_MAP_ID, help="Numeric map id for the static scene")
parser.add_argument(
"--map-label",
default="PSX LSET1/L0 Unverified Art Probe",
help="Human-readable map label for the catalog entry",
)
return parser.parse_args()
def load_records(path: Path) -> list[PlacementRecord]:
payload = json.loads(path.read_text(encoding="utf-8"))
records: list[PlacementRecord] = []
for row in payload.get("rows", []):
row_index = int(row["index"])
for side_index, side in enumerate(("left", "right")):
values = row.get(side)
if not isinstance(values, dict):
continue
record = PlacementRecord(
side=side,
row_index=row_index,
record_index=row_index * 2 + side_index,
u0=int(values.get("u0", 0)),
u1=int(values.get("u1", 0)),
u2=int(values.get("u2", 0)),
u3=int(values.get("u3", 0)),
u4=int(values.get("u4", 0)),
u5=int(values.get("u5", 0)),
)
if is_structured_candidate(record):
records.append(record)
records.sort(key=lambda record: (record.u2, record.u1, record.u0, record.record_index))
return records
def is_structured_candidate(record: PlacementRecord) -> bool:
if record.u0 >= 0x200:
return False
if record.u1 == 0 and record.u2 == 0:
return False
if record.u1 >= 0x4000 or record.u2 >= 0x4000:
return False
if record.u3 > 0x20 or record.u4 > 0x04:
return False
if record.u5 not in ALLOWED_U5:
return False
return True
def load_bundle_frames(summary_path: Path, sprite_root: Path) -> list[BundleFrame]:
bundle_frames: list[BundleFrame] = []
bundle_dirs = sorted(path for path in sprite_root.iterdir() if path.is_dir() and path.name.startswith("bundle_"))
for bundle_index, bundle_dir in enumerate(bundle_dirs):
bundle = json.loads((bundle_dir / "bundle.json").read_text(encoding="utf-8"))
bundle_offset = int(bundle.get("offset", 0))
palette_index = int(bundle.get("palette_index", 0))
frame_count = int(bundle.get("frame_count", 0))
for frame in bundle.get("exported_frames", []):
frame_index = int(frame.get("index", 0))
width = int(frame.get("width", 1))
height = int(frame.get("height", 1))
origin_x, origin_y = sanitize_origin(
int(frame.get("origin_x", 0)),
int(frame.get("origin_y", 0)),
width,
height,
)
source_png = bundle_dir / f"frame_{frame_index:03d}_color.png"
if not source_png.exists():
source_png = bundle_dir / f"frame_{frame_index:03d}.png"
if not source_png.exists():
continue
bundle_frames.append(
BundleFrame(
bundle_index=bundle_index,
bundle_offset=bundle_offset,
frame_index=frame_index,
frame_count=frame_count,
width=width,
height=height,
origin_x=origin_x,
origin_y=origin_y,
palette_index=palette_index,
source_png=source_png,
)
)
if not bundle_frames:
raise ValueError("No extracted sprite bundle PNGs were found under sprite_bundles.")
return bundle_frames
def build_bundle_frame_lookup(bundle_frames: list[BundleFrame]) -> dict[int, list[BundleFrame]]:
lookup: dict[int, list[BundleFrame]] = {}
for bundle_frame in bundle_frames:
lookup.setdefault(bundle_frame.bundle_index, []).append(bundle_frame)
for frames in lookup.values():
frames.sort(key=lambda frame: frame.frame_index)
return lookup
def build_scene(records: list[PlacementRecord], bundle_frames: list[BundleFrame], game_id: str, game_label: str, map_id: int) -> tuple[dict[str, object], list[tuple[Path, str]]]:
if not records:
raise ValueError("No structured PSX placement records survived the debug filter.")
min_x = min(record.u1 for record in records)
max_x = max(record.u1 for record in records)
min_y = min(record.u2 for record in records)
max_y = max(record.u2 for record in records)
bundle_lookup = build_bundle_frame_lookup(bundle_frames)
requested_bundles = sorted({record.u0 for record in records})
missing_bundle_indexes = [bundle_index for bundle_index in requested_bundles if bundle_index not in bundle_lookup]
if missing_bundle_indexes:
raise ValueError(f"Missing extracted PNGs for bundle indexes: {missing_bundle_indexes[:10]}")
shape_by_bundle = {bundle_index: 0x9000 + index for index, bundle_index in enumerate(requested_bundles)}
sprite_frames: dict[tuple[int, int], BundleFrame] = {}
sprite_copies: list[tuple[Path, str]] = []
atlases: list[dict[str, object]] = []
sprites: list[dict[str, object]] = []
for bundle_index in requested_bundles:
frames = bundle_lookup[bundle_index]
used_frame_indexes = sorted({min(record.u4, len(frames) - 1) for record in records if record.u0 == bundle_index})
shape = shape_by_bundle[bundle_index]
for used_frame_index in used_frame_indexes:
bundle_frame = frames[used_frame_index]
sprite_frames[(bundle_index, used_frame_index)] = bundle_frame
atlas_id = f"atlas-{bundle_index:03d}-{used_frame_index:03d}"
file_name = f"bundle_{bundle_frame.bundle_offset:08X}_frame_{bundle_frame.frame_index:03d}.png"
sprite_copies.append((bundle_frame.source_png, file_name))
atlases.append(
{
"id": atlas_id,
"fileName": file_name,
"width": bundle_frame.width,
"height": bundle_frame.height,
}
)
sprites.append(
{
"id": f"sprite:{shape}:{used_frame_index}",
"atlasId": atlas_id,
"shape": shape,
"frame": used_frame_index,
"x": 0,
"y": 0,
"width": bundle_frame.width,
"height": bundle_frame.height,
"xoff": bundle_frame.origin_x,
"yoff": bundle_frame.origin_y,
}
)
shape_definitions: list[dict[str, object]] = []
for bundle_index in requested_bundles:
frames = bundle_lookup[bundle_index]
bundle_frame = frames[0]
shape = shape_by_bundle[bundle_index]
shape_definitions.append(
{
"id": f"shape:{shape}",
"shape": shape,
"shapeHex": f"0x{shape:04x}",
"family": None,
"label": "Terrain",
"kind": "terrain",
"displayName": f"PSX unverified bundle probe u0={bundle_index:04X} -> bundle {bundle_index}",
"description": f"Unverified PSX art probe using region-01 u0 as direct sprite bundle index (bundle offset 0x{bundle_frame.bundle_offset:08X}).",
"dimensions": {"x": bundle_frame.width, "y": bundle_frame.height, "z": 1},
"visibilityTags": [],
"traits": {
"editor": False,
"roof": False,
"oob": False,
"occluding": False,
"translucent": False,
"solid": False,
"fixed": False,
"land": True,
"draw": True,
"invitem": False,
"animType": 0,
},
"catalogEntry": {
"humanReadableId": "",
"description": "",
"roof": None,
"semitransparency": None,
"oob": None,
},
"catalogOverrides": {
"roof": None,
"semitransparency": None,
"oob": None,
},
"tableFallback": None,
}
)
items: list[dict[str, object]] = []
min_screen_left = None
min_screen_top = None
screen_right = None
screen_bottom = None
fallback_frame_count = 0
for draw_order, record in enumerate(records):
anchor_x = (record.u1 - min_x) * SCREEN_SCALE
anchor_y = (max_y - record.u2) * SCREEN_SCALE
frames = bundle_lookup[record.u0]
chosen_frame_index = min(record.u4, len(frames) - 1)
if chosen_frame_index != record.u4:
fallback_frame_count += 1
bundle_frame = sprite_frames[(record.u0, chosen_frame_index)]
screen_left = anchor_x - bundle_frame.origin_x
screen_top = anchor_y - bundle_frame.origin_y
screen_right = screen_left + bundle_frame.width if screen_right is None else max(screen_right, screen_left + bundle_frame.width)
screen_bottom = screen_top + bundle_frame.height if screen_bottom is None else max(screen_bottom, screen_top + bundle_frame.height)
min_screen_left = screen_left if min_screen_left is None else min(min_screen_left, screen_left)
min_screen_top = screen_top if min_screen_top is None else min(min_screen_top, screen_top)
shape = shape_by_bundle[record.u0]
items.append(
{
"id": f"item:{draw_order}:psx-region01:{record.side}:{record.row_index}",
"mapSourceIndex": draw_order,
"drawOrder": draw_order,
"kind": "terrain",
"label": "Terrain",
"source": "psx-region01",
"world": {
"x": record.u1,
"y": record.u2,
"z": record.u0,
},
"mapNum": record.u5,
"npcNum": record.u4,
"nextItem": 0,
"quality": record.u0,
"frame": chosen_frame_index,
"screen": {
"left": screen_left,
"top": screen_top,
"right": screen_left + bundle_frame.width,
"bottom": screen_top + bundle_frame.height,
"width": bundle_frame.width,
"height": bundle_frame.height,
"anchorX": anchor_x,
"anchorY": anchor_y,
},
"flags": {
"raw": record.u3,
"hex": f"0x{record.u3:04X}",
"invisible": False,
"flipped": False,
},
"presentation": {
"opacity": 1,
"visibilityDefault": True,
},
"notes": [
f"PSX region-01 art probe record {record.side} row {record.row_index}",
f"raw words: {record.u0:04X} {record.u1:04X} {record.u2:04X} {record.u3:04X} {record.u4:04X} {record.u5:04X}",
f"provisional art mapping: bundle_index={record.u0} bundle_offset=0x{bundle_frame.bundle_offset:08X} requested_frame={record.u4} chosen_frame={chosen_frame_index} palette_index={bundle_frame.palette_index}",
],
"frameSize": {
"width": bundle_frame.width,
"height": bundle_frame.height,
"xoff": bundle_frame.origin_x,
"yoff": bundle_frame.origin_y,
},
"egg": None,
"npcPreview": None,
"itemPreview": None,
"shapeDefId": f"shape:{shape}",
"spriteId": f"sprite:{shape}:{chosen_frame_index}",
}
)
x_shift = -min(0, min_screen_left or 0)
y_shift = -min(0, min_screen_top or 0)
final_right = 0
final_bottom = 0
for item in items:
screen = item["screen"]
screen["left"] += x_shift
screen["right"] += x_shift
screen["top"] += y_shift
screen["bottom"] += y_shift
screen["anchorX"] += x_shift
screen["anchorY"] += y_shift
final_right = max(final_right, screen["right"])
final_bottom = max(final_bottom, screen["bottom"])
map_source_items = []
for record, item in zip(records, items):
chosen_frame_index = item["frame"]
bundle_frame = sprite_frames[(record.u0, chosen_frame_index)]
map_source_items.append(
{
"x": record.u1,
"y": record.u2,
"z": record.u0,
"shape": shape_by_bundle[record.u0],
"frame": chosen_frame_index,
"flags": record.u3,
"quality": record.u0,
"npcNum": record.u4,
"mapNum": record.u5,
"nextItem": 0,
"source": "psx-region01",
"rawWords": [record.u0, record.u1, record.u2, record.u3, record.u4, record.u5],
"recordSide": record.side,
"rowIndex": record.row_index,
"bundleOffset": bundle_frame.bundle_offset,
"paletteIndex": bundle_frame.palette_index,
"screenLeft": item["screen"]["left"],
"screenTop": item["screen"]["top"],
}
)
scene = {
"build": {
"version": DEBUG_SCENE_VERSION,
"fingerprint": "psx-lset1-l0-region01-debug",
"generatedAt": "2026-03-29T00:00:00.000Z",
"cacheMode": "single-scene",
},
"metadata": {
"game": game_id,
"gameLabel": game_label,
"map": map_id,
"rawItemCount": len(records),
"itemCount": len(records),
"paintedItemCount": len(records),
"occludedItemCount": 0,
"invalidItemCount": 0,
"invalidItems": [],
"sceneSummary": {
"atlasCount": len(atlases),
"spriteCount": len(sprites),
"helperCount": 0,
"kindCounts": {"terrain": len(records)},
"sourceCounts": {"psx-region01": len(records)},
"topFamilies": [{"family": None, "count": len(records)}],
},
"usage": {
"status": "research",
"confidence": "low",
"knownHints": [
"Uses real extracted LSET sprite bundle PNGs as an explicitly unverified art probe.",
"Current hypothesis is direct region-01 u0 -> sprite bundle index and u4 -> frame index.",
"This mapping is known to be incoherent and should not be treated as final art placement."
],
"itemMapNums": sorted({record.u5 for record in records}),
"nonzeroItemMapNums": sorted({record.u5 for record in records if record.u5 != 0}),
"npcLinkedItemCount": sum(1 for record in records if record.u4 != 0),
"note": "This is an unverified real-art PSX probe scene from filtered region-01 placement candidates, not a final decoded map format.",
"hasRenderableContent": True,
"game": game_id,
"map": map_id,
},
"baseItemSummary": {
"roofItems": 0,
"editorItems": 0,
"eggFamilyItems": 0,
"invisibleFlaggedItems": 0,
"npcLinkedItems": sum(1 for record in records if record.u4 != 0),
},
"sorter": "psx_region01_debug",
"isEmpty": False,
"emptyReason": None,
"bounds": {
"screenLeft": 0,
"screenTop": 0,
"screenRight": final_right,
"screenBottom": final_bottom,
"width": final_right,
"height": final_bottom,
},
"zoom": {
"min": 0.01,
"max": 8,
"step": 0.1,
"initial": 1,
},
"buildFingerprint": "psx-lset1-l0-region01-unverified-art-probe",
"generatedAt": "2026-03-29T00:00:00.000Z",
"probeStats": {
"fallbackFrameCount": fallback_frame_count,
"bundleIndexMin": requested_bundles[0],
"bundleIndexMax": requested_bundles[-1],
"bundleCountUsed": len(requested_bundles),
},
},
"atlases": atlases,
"sprites": sprites,
"shapeDefinitions": sorted(shape_definitions, key=lambda entry: entry["shape"]),
"items": items,
"mapSource": {
"formatVersion": DEBUG_SCENE_VERSION,
"game": game_id,
"mapId": map_id,
"itemRecordSize": 12,
"itemCount": len(map_source_items),
"originalByteLength": len(map_source_items) * 12,
"exportFileName": None,
"defaultTeleportEggShape": None,
"defaultTeleportEggShapeHex": None,
"defaultTeleportEggFrame": None,
"defaultTeleporterEggFrame": None,
"defaultTeleportDestinationEggFrame": None,
"binaryExportSupported": False,
"items": map_source_items,
},
}
return scene, sprite_copies
def write_catalog_entry(output_root: Path, game_id: str, game_label: str, map_id: int, map_label: str, raw_item_count: int, shape_definitions: list[dict[str, object]]) -> None:
catalog_path = output_root / "catalog.json"
catalog = json.loads(catalog_path.read_text(encoding="utf-8")) if catalog_path.exists() else {"games": []}
games = [game for game in catalog.get("games", []) if game.get("id") != game_id]
games.append(
{
"id": game_id,
"label": game_label,
"mapCount": 1,
"maps": [
{
"id": map_id,
"label": map_label,
"rawItemCount": raw_item_count,
}
],
}
)
games.sort(key=lambda game: game["label"])
catalog["games"] = games
catalog_path.write_text(json.dumps(catalog, indent=2) + "\n", encoding="utf-8")
catalogs_dir = output_root / "catalogs"
catalogs_dir.mkdir(parents=True, exist_ok=True)
csv_lines = [
"shape_code,human_readable_id,description,roof,semitransparency,OOB,categorization,qualities"
]
for definition in shape_definitions:
csv_lines.append(
",".join(
[
definition["shapeHex"],
definition["displayName"],
definition["description"],
"",
"",
"",
definition["kind"],
"",
]
)
)
(catalogs_dir / f"{game_id}.csv").write_text("\n".join(csv_lines) + "\n", encoding="utf-8")
def main() -> int:
args = parse_args()
records = load_records(args.input)
bundle_frames = load_bundle_frames(args.summary, args.sprite_root)
scene, sprite_copies = build_scene(records, bundle_frames, args.game_id, args.game_label, args.map_id)
maps_root = args.output_root / "maps" / args.game_id / f"map-{args.map_id}"
maps_root.mkdir(parents=True, exist_ok=True)
for source_png, file_name in sprite_copies:
shutil.copyfile(source_png, maps_root / file_name)
(maps_root / "scene.json").write_text(json.dumps(scene, indent=2) + "\n", encoding="utf-8")
write_catalog_entry(
args.output_root,
args.game_id,
args.game_label,
args.map_id,
args.map_label,
len(records),
scene["shapeDefinitions"],
)
print(
f"wrote PSX art probe scene: game={args.game_id} map={args.map_id} items={len(records)} unique_shapes={len(scene['shapeDefinitions'])} atlases={len(scene['atlases'])}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

1087
tools/psx_extract_wdl.py Normal file

File diff suppressed because it is too large Load diff