Skip to content

feat(): Constantine - #111

Merged
phobos665 merged 7 commits into
mainfrom
title/constantine
Nov 2, 2026
Merged

phobos665 merged 7 commits into
mainfrom
title/constantine

Conversation

@phobos665

Copy link
Copy Markdown
Owner

No description provided.

…self

With Begin and End replaced and SetVertexData* not (the 5960 online
dashboard: its symbols are not identified), the vertices between them
are in the push buffer, written by the title's own code, and End's
push_skip walked past them without drawing: every such quad had no
vertices. End now walks them in while the group is open. A title whose
SetVertexData* are replaced writes nothing there, so nothing changes
for it.
D3DDevice_End drew its vertices under the shader the API last selected.
A program started through the push buffer since then is what the NV2A
runs, and the walk's own draws already use it (pushed_program_begin);
End now does the same.

Constantine resolves its 1280x480 supersampled screen inside Swap with
one triangle drawn this way: the XDK starts a two-instruction program
(oPos = v0, oT0 = v9) and calls Begin / End. Under the title's 2D
program, which reads its texture coordinate from v2, every pixel
sampled one texel and each frame after the intro movies was a single
flat colour. The main menu now draws.
A title that plays its movies through D3DDevice_UpdateOverlay presents
without Swap, so RECOMP_HLE_D3D8_DUMP and F11 never saw those frames.
The overlay's own present now dumps first. Found on Constantine, whose
Bink intro movies go this way.
GetVisibilityTestResult answered every test "0 pixels" once a title spun
on it: nothing executes the push buffer, so no report is ever written.
Constantine culls its scenery and its hero by these tests, 53 a frame in
its first level, and Constantine himself and most of his apartment were
never drawn.

The push-buffer walk now hands SET_ZPASS_PIXEL_COUNT_ENABLE (0x17CC) and
GET_REPORT (0x17D0) to the HLE. Between the two the RHI counts the
samples of each host draw (rhi_occlusion_*: one Vulkan query a draw, so a
test can span a render-target change; one D3D11 query a test), and
GetVisibilityTestResult answers with the count of the newest closed test
whose report offset names its index (bits 4-11 of the offset, the XDK's
16-byte slots in a 4 KB page). Counts are scaled to guest samples. All
closed tests are read back together, one GPU wait a batch: one at a time
it was ~80 waits a frame and cost a fifth of the frame rate.

Capped, Constantine's level holds 60 fps; uncapped ~430 swaps / 5 s
against ~505 with RECOMP_HLE_D3D8_VISIBILITY=0, which also draws less.
The D3D11 half is not yet built or run on Windows.
From a run's log (tools.seed_from_log). 0x001DF280 is a callback the
title stores at 0x35A3F4; the disassembler ran a six-entry jump table at
0x1DF268 into it, so it was lifted as the middle of sub_001DF1B0 and
called over 1000 times unresolved.
Constantine's Bink player asks "is the next frame due?" -- a mutex taken
and released -- and reads the TSC round and round between movie frames:
about two million kernel calls a second, 83% of a core, during every
movie. The console spins the same way, but on a handheld it is heat and
battery for nothing.

A thread that reads the TSC 256 times within a millisecond of host time,
four windows running, now sleeps 1 ms (xbox_GuestSleep, so the guest lock
is dropped) and reads the clock afresh. A loop waiting for a moment
overshoots it by about that much at most; work that reads the clock as it
goes reads it far less often. RECOMP_TSC_POLL_SLEEP=0 lets it spin.

Constantine's intro movies, 50 s: main-thread CPU 44.2 s -> 6.3 s, mutex
waits 77.7M -> 5.4M; the same movie frames at the same times and the
movie's sound consumed at the same rate. In-level, uncapped, unchanged
(~438 swaps / 5 s).
The pass that finds the function after a switch's tables walks over the
byte index table, taking every byte below the dword table's entry count
as an index. A switch with more cases than the next function's first
bytes are worth cannot tell the two apart by value: Constantine's at
0x0004E220 has ~156 entries, so "56 8B F1" (push esi; mov esi, ecx) at
0x0004E490 read as two more indices, a function was started at the F1 in
the middle of the mov, and the lifted method lost "mov esi, ecx". Its
vtable calls went through whatever esi held -- the ICALLs to 0x804AF540
and 0 -- and the Select Difficulty menu was drawn with no options.

The walk now stops at an address that is named as code -- a function or
candidate start, a direct branch from inside a function, or a data word
beside another code pointer (a vtable; XBEs flag .rdata executable, so
code sections are told by name) -- and could start a function (first
byte 0x0F or above, a body that decodes). The usual skip at the walk's
end still looks only at existing starts, so a named function after the
tables is still recovered here. Branches decoded from the index bytes
themselves, lone data words and alias entries are not evidence: each
lost a real function in a first version of this.

Constantine against the previous disassembler: one entry fewer
(0x0004E492, the split) and nothing else changed; its difficulty menu
draws Easy, Medium and Hard.
@phobos665
phobos665 merged commit 571fa0b into main Nov 2, 2026
13 of 14 checks passed
@phobos665
phobos665 deleted the title/constantine branch November 2, 2026 09:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant