Skip to content

feat(): Tony Hawk's Underground - #118

Merged
phobos665 merged 10 commits into
mainfrom
title/thug
Nov 6, 2026
Merged

phobos665 merged 10 commits into
mainfrom
title/thug

Conversation

@phobos665

@phobos665 phobos665 commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Toolkit changes for Tony Hawk's Underground (EU, 0x4156002D, XDK 5659). The title project lives in its own repository and builds against this branch.

On macOS (Apple Silicon), THUG runs from boot into Story Mode gameplay in New Jersey, with the menus, cutscenes and Create-a-Skater on the way. It holds a native 60 fps (presentation interval 1, about 9 ms of work a frame) and uses its own 16:9 mode, which the console widescreen flag switches on. Sound goes through the native DirectSound path. No crashes and no unresolved indirect calls in two-minute runs.

Changes

The texture cache grows for a frame that binds more than 512 textures (src/hle/hle_d3d8_texture.c)

THUG's in-engine cutscenes bind about 600 distinct textures a frame. The cache held 512 entries and can't evict one bound in the current frame. With no victim left, every texture after the 512th got the white placeholder. The loading screen's sprites are drawn last, so the screen was pure white between scenes.

  • The 512 entries are kept and evicted exactly as before, so a title whose frames fit sees no change, and neither do its per-bind scans.
  • Only a frame with nothing left to evict grows the cache, up to 4096 entries. The 256 MB byte budget still bounds the memory they hold.
  • New log line: a texture refused because its Data is null or out of range is now named, once per format. Before, it only showed up in the "out of range" count. In THUG the only such texture is the dummy colour target of a depth-only shadow pass, which is harmless.

The disc's security sector read is answered (src/kernel/kernel_bridge.c)

From XDK 5xxx a certificate can ask XAPI to check the security sector as well as the security page. The request is bit 0 of the certificate dword at +0x1D8. With it set, XAPI sends READ DVD STRUCTURE for address 0xFF02FDFF and accepts the disc only if:

  • the sector carries the certificate's timestamp,
  • a SHA-1 of its first 0x4CB bytes matches the digest stored in it, and
  • its signature verifies.

The pass-through refused that command. So THUG's unmodified default.xbe rebooted into itself three times and quit; only a hard-drive copy with the check patched out would start. A disc image has no security sector, so one is now made for the running title, holding its timestamp and the digest. The signature already passes, because XcVerifyPKCS1Signature returns TRUE. Both the disc's XBE and the patched hard-drive copy now reach gameplay.

The fog register reads no fog while fog is disabled (src/d3d/d3d8_combiners.c)

The combiners took the fog register's alpha from the vertex stage's fog output whether or not fog was on. The NV2A gives a factor of 1 (no fog) when it is disabled, as xemu does, and a final combiner reads the register either way. THUG's skater shaders draw with fog off and use (1 - fog.a) * FC0 as a per-channel fog weight, with FC0 = 0x80008000. Read as fully fogged, the green channel was pulled halfway to the fog colour on every skater: magenta skin and purple clothes, in menus, cutscenes and in-level.

A shadow-map compare clamps its reference to the depth range (src/d3d/d3d8_combiners.c)

A fixed-point depth compare clamps the reference to the format's range; xemu's does too, through GL's shadow samplers. Without the clamp, a receiver past the light's far plane read deeper than any stored depth and failed the test. THUG projects its skater's shadow onto the ground polygons around them, and every polygon reaching past the shadow camera's range came out as a solid dark band or triangle. Now such a receiver compares as the far plane, and a cleared map lights it. This affects any title that samples a shadow map.

A stand-in for the device depth gets the device depth's clear (src/hle/hle_d3d8.c)

A title can pair one of its own render-target textures with the device's depth buffer. The host can't when their sizes differ: the device depth is supersampled with the scene, and a title's render-target texture is not. The pass then gets a scratch depth, which never saw the clear the title made to the device depth at the start of its frame. Now the scratch gets the device depth's latest whole clear before it's used, once per clear.

During a menu change, THUG draws the new menu into a 640x480 texture with the device depth and fades it in over the old screen. Against the uncleared scratch, every 3D draw failed its depth test, so the skater and the city panel were missing. Half the screen faded to black, and stale pixels from an earlier transition showed through as a smeared logo. RECOMP_HLE_D3D8_DEVZ_STAND_IN=0 restores the old behaviour.

The XDK's antialiasing filter is not drawn over the frame (src/hle/hle_d3d8.c, src/hle/hle_d3d8_texture.c)

With multisampling on, the XDK's Swap filters the back buffer into a separate front buffer. THUG's back buffer is 1280x480, 2x across. On the host, back and front are the same scene, so the filter pass only resampled the picture over itself. A normal frame came out slightly soft. Menu transitions fade each frame over the last, and there the blur compounded until the menu was a smear. The filter is a draw that samples the frame buffer at an exact multiple of its target's size, and it is now skipped. A pass over the frame at its own size, such as a glow or a colour grade, still draws. RECOMP_HLE_D3D8_SKIP_AA_FILTER=0 restores the old behaviour.

A screen-sized render target is supersampled with the scene (src/d3d/d3d8_device.c, src/d3d/d3d8_resources.c, src/d3d/d3d8_screencopy.c)

A colour render-target texture exactly the title's screen size now gets a host image at the scene's supersampling, while the texture keeps the title's size. Before, a title that rendered a whole frame into a texture dropped to console resolution while it did. Several things now follow the target's host size:

  • The viewport, scissor and clears.
  • The depth-size check, so the device depth fits such a target and is shared rather than stood in for.
  • Screen copies into such a texture.

Uploads at the title's size are not written over what the GPU drew. RECOMP_D3D8_SCALE_SCENE_TARGETS=0 keeps every texture at the title's size.

A full-screen colour ramp is a whole-screen pass in centred 2D (src/d3d/d3d8_device.c)

With 2D kept at 4:3 (CENTRE_2D, widescreen_2d = centre), only whole-screen passes span the picture: flat colour (up to 4x4 texels) or the frame itself. A full-screen quad through a texture at most 8 texels across one way is a colour ramp, which stretching distorts nothing of. THUG tints its paused game through a 256x4 texture, which in the box left the picture's sides untinted. This only affects titles with centred 2D (Gladius among them).

Pads open without enumeration; pre-4134 DirectSound on the APU path

This is the THPS3 commit from title/thps3, cherry-picked because that branch was never merged:

  • XInputOpen refreshes which ports are connected. THPS3 opens pads without enumerating them.
  • A DirectSound build older than 4134 defaults to RECOMP_AUDIO=apu.
  • RECOMP_INPUT_LOG also logs device enumeration and failed opens.

THUG relies on neither of these behaviours. If this merges, title/thps3 doesn't need a PR of its own.

Testing

  • macOS arm64, THUG: scripted route to gameplay, 4:3 and 16:9. Frame dumps checked: the white loading screen is gone and the cache grows to about 600 entries.
  • THUG from the disc's unmodified default.xbe (SHA-1 408756c0…): boots and plays the same route.
  • THUG uncapped (RECOMP_FPS_CAP=0): 120 fps on a 120 Hz display.
  • THUG skater colours, in a replayed capture and a live run (menus, cutscenes, in-level).
  • THUG in-level: no dark bands or triangles. A live run with the shadow test made visible shows only the skater and their own shadow on the ground. 60 fps at 3x supersampling.
  • THUG menu transitions (main menu to Story Options and back), frame by frame: the fade is clean, with no black half and no logo smear.
  • THUG menu transitions as sharp as the menus around them, checked in crops of live frame dumps at 3x. The front end, the story intro and Create-a-Skater render normally.
  • Windows regression of TS2 and BLiNX. These commits change shared HLE, kernel and combiner code; the fog change alters any title whose final combiner reads fog while fog is off, the shadow clamp any title that samples a shadow map, and the render-target scaling any title that renders into a screen-sized texture (Gladius's scene snapshots among them).

…APU path

Tony Hawk's Pro Skater 3 (XDK 4039) brought up two gaps.

Input: the connected ports were refreshed only inside XGetDevices and
XGetDeviceChanges for the gamepad type. THPS3 enumerates memory units only
and calls XInputOpen straight away, so every open failed and its menus never
read the pad (only its attract demo, much later, got one). XInputOpen now
refreshes them too, as the console's USB attaches a pad whether or not the
title asked. RECOMP_INPUT_LOG also logs the enumeration calls and failed
opens, with the caller.

Audio: the native path reads buffer settings in the 4134+ layout, so no
buffer of an older DirectSound is ever modelled (settings_in_layout). With no
RECOMP_AUDIO/audio setting, such a title now starts on the APU path: THPS3
had its music and no sound effects natively, and every buffer heard there.
With every entry bound in the current frame there was no victim, and
each texture after the 512th got the white placeholder. Tony Hawk's
Underground's in-engine cutscenes bind about 600; its loading screen's
sprites are drawn last, so the screen was pure white between scenes.

512 entries are still kept and evicted as before, so a title whose
frames fit sees no change, its per-bind scans included. Only a frame
with nothing to evict grows the cache, to at most 4096; the byte budget
still bounds what they hold.

Also names, once per format, a texture refused for a null or
out-of-range Data, which was only a count.
From XDK 5xxx a certificate can ask XAPI to check the security sector
as well as the security page: with bit 0 of certificate +0x1D8 set it
sends READ DVD STRUCTURE for 0xFF02FDFF and accepts the disc only if the
sector carries the certificate's timestamp and a SHA-1 of its first
0x4CB bytes, and its signature verifies. The pass-through refused the
command, so Tony Hawk's Underground's unmodified default.xbe rebooted
into itself three times and quit; only a hard-drive copy with the check
patched out started.

A disc image has no security sector, so one is made for the running
title: its certificate's timestamp and the digest. The signature already
passes (XcVerifyPKCS1Signature returns TRUE).
The combiners' fog register took its alpha from the vertex stage's fog
output whether or not fog was enabled. The NV2A's fog unit gives a
factor of 1 when it is off (xemu: oFog = 1.0), and a final combiner
reads the register either way.

Tony Hawk's Underground's skater shaders draw with fog off and compute
(1 - fog.a) * FC0 as a per-channel fog weight, with FC0 = 0x80008000:
read as fully fogged, the green channel went halfway to the fog colour
on every skater, in menus, cutscenes and in-level -- magenta skin,
purple clothes.
With 2D kept at 4:3 (CENTRE_2D, widescreen_2d = centre), only a
whole-screen pass spans the picture: flat colour, up to 4x4 texels, or
the frame itself. A full-screen quad through a colour ramp is the same
kind of pass, and stretching it distorts nothing. Tony Hawk's
Underground tints its paused game through a 256x4 texture; kept in the
box, it left the sides of the picture untinted. A texture at most 8
texels across one way now counts; a real picture over the whole screen
still stays at 4:3.
A fixed-point depth compare clamps the reference to the format's range
(xemu's, through GL's shadow samplers, does the same), so a receiver past
the light's far plane compares as the far plane and a cleared map lights
it. Unclamped, every ground polygon Tony Hawk's Underground projects its
skater's shadow onto that reached past the shadow camera's range failed
the test and came out a solid dark band or triangle.
A title can pair one of its own render target textures with the device's
depth. The host cannot when their sizes differ (the device depth is
supersampled with the scene, a title's render target texture is not), so
the pass gets a scratch depth -- which never saw the clear the title made
to the device depth at the start of its frame. The scratch now gets the
device depth's latest whole clear before it is used, once per clear.

Tony Hawk's Underground draws its menus into a 640x480 texture with the
device depth during a menu transition and fades it over the old screen.
Against the uncleared scratch every 3D draw failed its depth test: the
skater and the city panel were missing, half the screen faded to black,
and stale pixels from an earlier transition (the old logo, smeared) showed
through. RECOMP_HLE_D3D8_DEVZ_STAND_IN=0 for the old behaviour.
…ets supersampled

The XDK's antialiasing filter samples the multisampled back buffer (THUG's
is 1280x480, 2x across) into the front buffer in Swap's own body. On the
console that writes a buffer of its own; here both are the one scene the
host presents, so the pass resampled the picture over itself -- a slight
softening each frame, and where the title fades each frame over the last
(THUG's menu transitions) a blur that compounded into a smear. A draw that
samples the frame buffer at an exact multiple of its target's size is that
filter and is not drawn (RECOMP_HLE_D3D8_SKIP_AA_FILTER=0 draws it); a pass
over the frame at its own size still draws.

A colour render-target texture exactly the title's screen size is now drawn
at the scene's supersampling: the texture keeps the title's size, its host
image is the scene's. A title that renders a whole frame into a texture no
longer drops to console resolution while it does. Viewport, scissor and
clear follow the target's host size, its depth must match that size, the
screen copy into such a texture fills it at its host size, and uploads of
the title's size are not written over what the GPU drew. The device depth
fits such a target, so it is shared rather than stood in for.
RECOMP_D3D8_SCALE_SCENE_TARGETS=0 keeps every texture at the title's size.
The previous commit rewrote the file with LF endings, which made every line
of it a change. Only the screen-copy sizing lines differ now.
…ed alone

d3d8_CreateTextureImpl now asks the device whether a render-target texture
is supersampled with the scene. The smoke tests that link d3d8_resources.c
against stub devices had no such symbol and failed to link on all three CI
platforms; their stub answers no, as an unscaled device would.
@phobos665
phobos665 merged commit 962a950 into main Nov 6, 2026
5 of 7 checks passed
@phobos665
phobos665 deleted the title/thug branch November 6, 2026 10:16
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