Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
run: |
python tools/brandingtest.py
python tools/experience/test_bosses.py
python tools/bosses/test_local_vs_boundary.py
python tools/experience/test_repository.py
python tools/experience/test_diagnostics.py
python tools/experience/test_normal_lifecycle.py
Expand Down
132 changes: 132 additions & 0 deletions docs/project/LOCAL-VS-BOSSES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Local VS boss execution boundaries

**Implementation status: incomplete.** Local VS bosses are not enabled, and no
playable B03 candidate is available. This record and its tools establish native
boundaries that must be resolved before exposing the optional feature.

Frozen B02: `b6518d3f3c0777335fbb2100c1b99e8304797d2b`.
Development branch: `feature/playable-bosses-local-vs`, directly from B02.
Upstream single-player implementation remains Pairomaniac and Vortyne's work;
hyp36rmax maintains the VO PATCH EX integration and expansions.

## Native evidence

`tools/bosses/test_local_vs_boundary.py` executes the actual linked B02 routines
in Unicorn, preserving register and stack checks. These are tests of the existing
boundary, not evidence that a Local VS extension works.

| Boundary | Result |
|---|---|
| `selmax_a` | VERIFIED: both cursor indices allow boss positions only in mode 0. Local mode 1 and network mode 2 retain the bound of seven. |
| `zfire_a` | VERIFIED: the A player object uses either decoded turbo edge (`0xff`); a held value does not retrigger. |
| B object input through `zfire_a` | VERIFIED: turbo edges on the known B object candidates do not take the A player special-attack path. This fixture does not establish which candidate owns P2 in every runtime state. |
| Four editions | VERIFIED: linked-code fixtures run for Retail, JPRE, OEM and JP. The B object candidate checks use Retail/JPRE addresses only. No pristine OEM/JP execution or physical gameplay is claimed. |

The game has two engine copies, each with a subject/opponent pair. An engine
copy is not a permanent human-player identity. The A opponent object is not the
B controlled object. Player role and camera state can select different members
of each pair. A safe extension must identify both engine and object ownership.

## Candidate mapping

`tools/bosses/local_vs_audit.py` scans recognized pristine executables without
writing them. It compares 16-instruction windows, masking relocations and branch
destinations. Every source window must match its original location. A candidate
is **NEEDS VALIDATION**, even if unique: caller reachability, operand ownership,
stack continuation and resource lifetime are not proved by opcode similarity.

Retail and JPRE each expose 93 inspected code windows; 54 have one alternate
match. Selected findings:

| Native boundary | Retail candidates | JPRE candidates | Status |
|---|---|---|---|
| AI tick continuation | `4c736f`, `4c7435`, `5b8c0b` | `4c4f0f`, `4c4fd5`, `5b35e3` | UNKNOWN owner; three matches each |
| Model-clone continuation | `468e79`, `4eb949`, `5773b3` | `467ebd`, `4e89cd`, `57269f` | UNKNOWN owner; three matches each |
| Z-Gradt draw entry | None in this comparison | None in this comparison | UNKNOWN; absence of a pattern match does not mean the game lacks the routine |
| Clip bank 0 continuation | `4c5798` | `4c33c0` | NEEDS VALIDATION |
| Clip bank 3 continuation | `4c57b2` | `4c33da` | NEEDS VALIDATION |

Addresses are native virtual addresses, not file offsets. There is no assumed
constant A-to-B or cross-edition delta.

## Resource contracts still requiring implementation

The existing boss flag, palette owner, model clone, AI bank, temporary state,
laser lock-on, gauge state and replay sidecar are single-player state. Z-Gradt's
banking/model-copy paths are guarded by the final-stage/player conditions; they
are not a demonstrated arbitrary-arena, two-human implementation.

The following contracts remain UNKNOWN or NEEDS VALIDATION:

1. B initialization, draw, weapon/update and cleanup entry/exit contracts.
2. Which engine/object pair owns each human input in selection, fight and replay.
3. Per-object AI/model/palette/event storage and the lifetime of shared assets.
4. Simultaneous laser state and complete replay save/restore boundaries.
5. P2 boss camera/HUD/animation and round-result paths.

Selection-only unlocking is UNSUITABLE: it bypasses the existing guard without
repairing these dependencies. No guessed hook, second copy of the complete boss
implementation, placeholder option or unverified generated recipe was added.

## Remaining B03 scope

After those adapters are verified, add one independently optional local-mode
extension requiring Playable Bosses. Mode 0 must retain B02 unlocks and behavior;
mode 2 must retain the original online behavior. Mode 1 must allow the original
eight, Jaguarandi and Z-Gradt for either player without unlock requirements.

All eight required orientations remain NEEDS VALIDATION: Jaguarandi/standard,
standard/Jaguarandi, Z-Gradt/standard, standard/Z-Gradt, Jaguarandi/Jaguarandi,
Jaguarandi/Z-Gradt, Z-Gradt/Jaguarandi and Z-Gradt/Z-Gradt. No balancing, damage
scaling or artificial weapon restrictions are permitted.

The existing 512 boss-free and 2,048 B02 catalog entries remain untouched.
Controller assignments, F7, movie ownership, display modes, networking,
Apply/Restore and saved progression remain unchanged. A B03 UAT ZIP must only
be assembled once the native extension and its tests exist; renaming B02 would
not provide a Local VS test candidate.

## Player B initialization and input (B03-P02)

The pristine engine already provides more of the required path than the first
candidate scan established. `tools/bosses/test_player_b_native.py` executes two
bounded native slices in Retail and JPRE, without changing instructions or
substituting callees. It refuses unrecognized executable hashes.

| Contract | Retail | JPRE | Evidence |
|---|---|---|---|
| B combatant initializer | `4c74e6` | `4c5086` | VERIFIED through callback selection, before environment setup |
| Initialization continuation | `4c7695` | `4c5235` | Terrain, palettes and subsequent setup are outside the fixture |
| B machine-model table | `6070a0` | `601e30` | VERIFIED: ten entries, including both bosses |
| Jaguarandi mode-0 callback | `4c722e` | `4c4dce` | VERIFIED: same callback choice as the original eight |
| Z-Gradt mode-0 callback | `4c742c` | `4c4fcc` | VERIFIED: distinct callback and setup branch |
| Z-Gradt setup entry | `5771d1` | `5724bd` | VERIFIED entry reached; its body is not executed by this fixture |
| B logical-input decoder | `4c5772` | `4c339a` | VERIFIED through input-bank selection and initial direction decoding |

The initializer takes ten arguments: combatant, opponent, object mode, machine
identity, opponent identity, palette side, model pointer, X, Z and angle. Its
initial slice stores identity at `+0x64`, model at `+0x6c`, opponent at `+0x74`
and an update callback at `+4`. Both native B objects accept these values. The
fixture covers all ten identities and modes 0, 1, 3 and 4, stopping before
external setup work. It does not establish that every mode is a human path.

For mode 0, Jaguarandi uses the existing normal-machine callback. That callback
calls B's existing movement/update and model routine. Z-Gradt uses its separate
setup and callback; replacing that callback with Jaguarandi's is UNSUITABLE.
There is no evidence here that replacement player objects or duplicate combat
systems are required.

The input slice reads bank zero for mode 0 and the alternate bank for mode 3.
This decision does not inspect machine identity. Both existing B objects were
exercised with all ten identities and distinct bank sentinels. The other B
object and the A player object remained unchanged in these bounded tests.
Machine identity alone therefore does not require a new controller decoder.
This does not prove full-match input ownership, weapon execution or camera
routing. Modes and engine copies must not be relabeled as permanent P1/P2 IDs.

Each edition passes 80 initialization-dispatch cases and 40 input-bank cases.
These are native contract tests, not complete initialization or physical play.
No runtime hook, selection option, recipe or UAT package is introduced by P02's
current changes. Player B gameplay implementation remains incomplete. Next work
must connect Local VS selection to these existing initialization paths and
verify downstream model, weapon, collision, camera and match-lifecycle behavior.
72 changes: 72 additions & 0 deletions tools/bosses/local_vs_audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Find instruction-shape candidates for native boss adapters, without patching.

A match is not proof of player ownership or a safe hook continuation. In
particular, absolute relocations and branch destinations are masked; callers,
object lifetimes and side effects still require separate verification.
"""
import argparse
import bisect
import hashlib
import json
from pathlib import Path
import re
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import selftest
import vomap


def audit(path):
v = selftest.load_patcher()
digest = hashlib.md5(path.read_bytes()).hexdigest()
if digest not in v.BUILDS:
raise ValueError('Expected a recognized pristine executable')
build = v.BUILDS[digest]
exe = vomap.Exe(str(path))
text = exe.d[exe.off(exe.text_lo):exe.off(exe.text_hi)]
relocations = sorted(exe.relocs)
symbols = {}
for name, address in build.symbols.items():
if not name.startswith('BS_') or not isinstance(address, int):
continue
if not exe.text_lo <= address < exe.text_hi:
continue
raw = exe.d[exe.off(address):exe.off(address) + 128]
instructions = list(vomap.md.disasm(raw, address))[:16]
if len(instructions) < 6:
continue
size = instructions[-1].address + instructions[-1].size - address
mask = set()
for insn in instructions:
begin = bisect.bisect_left(relocations, insn.address)
end = bisect.bisect_left(relocations, insn.address + insn.size)
for relocation in relocations[begin:end]:
mask.update(range(relocation-address, relocation-address+4))
if insn.group(vomap.capstone.CS_GRP_JUMP) or insn.group(vomap.capstone.CS_GRP_CALL):
if insn.imm_size:
offset = insn.address-address+insn.imm_offset
mask.update(range(offset, offset+insn.imm_size))
pattern = b''.join(b'.' if i in mask else re.escape(bytes([raw[i]])) for i in range(size))
matches = [exe.text_lo + match.start() for match in re.finditer(b'(?=('+pattern+b'))', text, re.S)]
assert address in matches, (build.short, name)
symbols[name] = {'source': hex(address), 'window_size': size,
'instruction_count': len(instructions),
'candidates': [hex(a) for a in matches if a != address]}
assert hashlib.md5(path.read_bytes()).hexdigest() == digest
return {'edition': build.short, 'original_md5': digest,
'status': 'NEEDS VALIDATION: candidate addresses, not verified B adapters',
'symbols': symbols}


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('original', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
result = audit(args.original)
args.output.write_text(json.dumps(result, indent=2)+'\n')
print(result['edition'], len(result['symbols']), 'code windows; executable unchanged')


if __name__ == '__main__':
main()
81 changes: 81 additions & 0 deletions tools/bosses/test_local_vs_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Execute frozen boss entry points; distinguish SP guarantees from VS support.

These are evidence fixtures for B03, not tests of an implemented VS extension.
They deliberately preserve B02's existing behavior until verified replacements
are available. No game files are patched or copied.
"""
from pathlib import Path
import struct
import sys
from unicorn import Uc, UC_ARCH_X86, UC_MODE_32
from unicorn.x86_const import UC_X86_REG_EAX, UC_X86_REG_ECX, UC_X86_REG_EBP, UC_X86_REG_ESP, UC_X86_REG_EFLAGS
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import selftest

v = selftest.load_patcher()
STACK = 0x10000000
STOP = 0x11000000
B_OBJECTS = {'retail': (0x01ef8ab0, 0x01ef9f60),
'jpre': (0x01ef5450, 0x01ef5ad0)}

class Fixture:
def __init__(self, build):
self.build = build
self.uc = Uc(UC_ARCH_X86, UC_MODE_32)
self.pages = set()
self.base = v.cave_va('BOSSES', build)
self.labels = v.BLOBS['BOSSES'][2]
blob = v.link('BOSSES', build)
self.write(self.base, blob)
self.write(STACK, bytes(4096))
self.write(STOP, b'\x90')

def write(self, address, data):
for page in range(address & ~4095, (address + len(data) + 4095) & ~4095, 4096):
if page not in self.pages:
self.uc.mem_map(page, 4096)
self.pages.add(page)
self.uc.mem_write(address, data)

def word(self, address, value):
self.write(address, struct.pack('<I', value))

def run(self, name, eax=0, obj=0):
self.word(STACK + 2048, STOP)
self.word(STACK + 1032, obj)
self.uc.reg_write(UC_X86_REG_ESP, STACK + 2048)
self.uc.reg_write(UC_X86_REG_EBP, STACK + 1024)
self.uc.reg_write(UC_X86_REG_EAX, eax)
self.uc.reg_write(UC_X86_REG_ECX, 0x55667788)
self.uc.emu_start(self.base + self.labels[name], STOP, count=1000)
assert self.uc.reg_read(UC_X86_REG_ESP) == STACK + 2052
return self.uc.reg_read(UC_X86_REG_ECX), self.uc.reg_read(UC_X86_REG_EFLAGS)

for build in v.BUILDS.values():
f = Fixture(build)
f.word(f.base + f.labels['boss'], 9)
a = build.symbols['BS_OBJA']
f.write(a, bytes(0x200))
f.write(a + 0x142, b'\xff\x00')
assert f.run('zfire_a', obj=a)[0] == 255
f.write(a + 0x142, b'\x00\xff')
assert f.run('zfire_a', obj=a)[0] == 255
f.write(a + 0x142, b'\xfe\x00')
assert f.run('zfire_a', obj=a)[0] != 255
# The SP hook accepts only the A object, not the other native engine.
for b in B_OBJECTS.get(build.short, ()):
f.write(b, bytes(0x200))
f.write(b + 0x142, b'\xff\xff')
assert f.run('zfire_a', obj=b)[0] == 0
assert bytes(f.uc.mem_read(a + 0x142, 2)) == b'\xfe\x00'
for mode in (0, 1, 2):
f.word(build.symbols['GAMEMODE'], mode)
f.word(f.base + f.labels['unl_level'], 2)
for player_index in (0, 21):
f.word(build.symbols['BS_SELCUR'] + player_index * 4, 8)
ecx, flags = f.run('selmax_a', eax=player_index)
assert ecx == 0x55667788
# cmp cursor, bound: 8 < 9 only for single-player mode.
assert bool(flags & 1) == (mode == 0)
print('PASS frozen selection/laser native boundaries:', build.short)
print('VERIFIED: B02 SP laser and selection contracts. Local VS is NOT implemented by these fixtures.')
Loading
Loading