Skip to content

Renderer, pacing, Android and texture pack work from bringing up God of War: Ghost of Sparta - #44

Open
arnaldolima1 wants to merge 29 commits into
TeamGDB:mainfrom
arnaldolima1:sparta
Open

arnaldolima1 wants to merge 29 commits into
TeamGDB:mainfrom
arnaldolima1:sparta

Conversation

@arnaldolima1

@arnaldolima1 arnaldolima1 commented Oct 14, 2026 •

Copy link
Copy Markdown

Framework changes made while bringing up God of War: Ghost of Sparta (UCES-01401) on PortableKit, in the port gow-ghost-of-sparta-pc. Each is something a PSP or another game can hit, not specific to that game. Behavior that changes for every game is listed with the switch that restores the old behavior. Behavior a game has to ask for is a GameProfile field that defaults to off.

The 13 commits are independent enough to review one by one, and the commit messages give the details and measurements.

Renderer: correctness

Change Why Default / off switch
GE stencil test (0x24, 0xDC, 0xDD, alpha mask 0xE9) It was ignored. Ghost of Sparta marks surfaces in the stencil and draws its bloom only where marked; without it the bloom covered the whole screen. The stencil lives in a stencil buffer (D32S8, else D24S8). Alpha clears clear it only on targets that have used it, so other targets keep their depthless passes. On; NO_STENCIL
Framebuffers inside another target's area A framebuffer pointer inside an existing target with the same stride and format is the same VRAM on a PSP; it is now drawn into that target at its offset. The game draws four 64×64 shadow maps 64 px apart in one buffer, blurs the row and projects each map. As separate targets the blur and the projections read empty or stale pixels. Frame skipping treats such an address as its base target. On; NO_TARGET_ALIASES
Clamped textures read from part of a target A 3D draw (or a through draw with coordinates outside the texture) reading a sub-rectangle of a target now samples a copy of just that part, so GE clamp and repeat work at the texture's edges, not the target's. On; NO_FB_EXTRACTS
New render targets start as zeros They were undefined: magenta on MoltenVK where a bloom chain samples around what it drew. On; NO_TARGET_CLEAR

Performance (measured on a Moto g84, Adreno 619)

  • Draw batching in GeState (NO_DRAW_BATCH): prims drawn in a row with no state change between them reach the renderer as one draw (triangles/strips/fans as one indexed list, unindexed sprites appended). Ghost of Sparta makes ~6600 prims a frame of ~9 vertices; 790 now reach the renderer and the display-list time drops from 8.7 to ~6 ms, taking the heaviest scene from 90% to 100% speed at ×2 without frame skipping.

  • Partial target copies, depthless passes on tiled GPUs, cheaper framebuffer-texture checks: 45 → 55–58 fps in the same scene. Off switches: NO_PARTIAL_COPIES, NO_DEPTHLESS_PASSES, NO_FB_CHECK_CACHE.

  • Render passes over only the area drawn, instead of the whole target (NO_PASS_AREA).

  • Frame skipping (Video → Frame skip, on by default): keeps a slow device at full game speed. Never two frames in a row; buffers kept across frames are still drawn.

  • Video → Limit to 30 fps (games that make 60): every other frame is skipped at full game speed, nothing while the game itself runs at 30. It holds 100% where drawing every frame ran at 85–90%.

  • GameProfile::frame_writeback (default on) lets a game that never reads its own picture with the CPU skip the per-flip VRAM write-back.

  • Target snapshots read guest memory directly, which had shown up in phone profiles.

Pacing

A flip that repeats the same buffer within a vblank is presented once (PRESENT_EVERY_FLIP). GameProfile::flips_wait_for_vblank (off by default) is for games whose frame loop waits only on the GE: they flipped ten times a vblank and then hitched.

Interface, Android, texture packs

  • Launcher (GameProfile::launcher, off unless a profile sets a theme): side bar, home page and Play button, with the menu's pages inside it.
  • Interface translations: English and Brazilian Portuguese (ui.language).
  • GameProfile::adhoc_play: hides the Network page for single-player games.
  • Android fixes: launcher art in the APK, spaced touch face buttons, tappable Back/Close hints. A debuggable build reads <prefix>_* switches from env.txt.
  • Texture packs: GameProfile::texture_pack_releases lets packs made for another release with the same textures import and load. A PPSSPP pack for UCUS98737 keys UCES01401's textures exactly. On Android, Import texture pack… now uses the system folder picker, copies the pack with progress and cancel, checks it and installs it. The JNI folder listing now carries file sizes.
  • GameProfile::start_skips_movies (off by default): START ends an sceMpeg movie by reporting the stream as finished.
  • GameProfile::default_volume.

Traces

TRACE_GE_CALLS, TRACE_PASSES, TRACE_STENCIL, TRACE_ALIASES. TRACE_FB_TEXTURES now prints every distinct value an ignored GE command receives (first 16), which is how the stencil use was found.

Testing

Tested:

  • God of War: Ghost of Sparta on macOS (Apple M4, MoltenVK) and Android (Moto g84 / Adreno 619): logos, title, opening movie, the first level, pause screens.
  • Window captures before and after each renderer change.
  • portablekit_texture_pack_tests (new cases for other-release packs), portablekit_settings_tests and portablekit_profile_tests pass. portablekit_host_check needs installed test-game data and was not run.

Not tested:

  • Other games. The stencil, target-alias and extract changes apply to every game; each has its off switch above.
  • The Android picker import, end to end on a device. It builds, and the find/copy/check logic is shared with the tested desktop path.
  • Windows and Linux.

🤖 Generated with Claude Code

arnaldolima1 and others added 29 commits October 13, 2026 10:55
A game that draws only part of a buffer and samples around it reads the
pixels it never drew. On a PSP those hold what VRAM held, normally zeros;
here a new target started undefined, and MoltenVK showed it as magenta.
A bloom chain drawn side by side in one 512-wide buffer, each step at its
own address, spread that magenta over the whole picture.

New targets are now cleared to zero once, when they are first used.
<prefix>_NO_TARGET_CLEAR restores the old behaviour.

Also: <prefix>_TRACE_GE_LIST_WORDS sets how many words each traced
display-list run prints (20000 before, and by default).

Verified on macOS arm64 (Apple M4, MoltenVK) with God of War: Ghost of
Sparta (UCES01401) under the interpreter: the title menu, magenta before,
draws with its own colours. Not verified on Windows, Linux or Android,
nor with any other game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a vblank

sceDisplaySetFrameBuf presented every call. The display reads the
framebuffer at the vblank, so setting the same buffer again within one
vblank period with nothing drawn since is no longer presented again
(<prefix>_PRESENT_EVERY_FLIP restores the old behaviour).

GameProfile::flips_wait_for_vblank, off by default: a game whose frame
loop waits on nothing but the GE (which takes no emulated time here)
flipped as fast as the host could draw, ten times a vblank, until the
busy clock charged 100 ms at once and the pacer held the game for it:
a hitch every few frames, emulation at 77% and audio underruns. With it
on, a second flip within one vblank period waits for the next vblank
(<prefix>_FLIP_UNLIMITED turns it off).

Verified on macOS arm64 (Apple M4, MoltenVK) with God of War: Ghost of
Sparta (UCES01401), recompiled: title menu at 60 fps and 100% speed, no
frame over 20 ms, no audio underruns. With frame_vblanks left at 2 the
presents stall (0 fps) while the game flips at 60; the profile sets 1.
That interaction is not fixed here. Not verified on other platforms or
with other games.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A profile's LauncherTheme puts a launcher before the game: a side bar
with the game's logo and the menu's pages, a home page with a banner and
quick settings, and a Play button. The menu draws its pages inside it, so
every setting lives in one place before and during play. Its art is the
player's: images in the data folder's launcher/ (or an app's
Resources/launcher/), else PIC1.PNG and ICON0.PNG read from their disc
image. --play skips it once, --launcher shows it once, and the player
can turn it off. It backs the saves up when Play is pressed, keeping the
newest ten.

The theme's colours become the whole interface's: the widget palette is
set at start instead of fixed, and text on an accent fill has a colour of
its own.

The interface speaks English and Brazilian Portuguese. Widgets look up
what they draw in a table (ui/i18n.hpp); sentences built around names use
patterns. The language is a setting (ui.language), the system's by
default.

GameProfile::adhoc_play false drops the Network page and the multiplayer
rows for a game without multiplayer.

The window is titled with the project's name rather than the
executable's.

Tested on macOS (Apple Silicon, MoltenVK) with God of War: Ghost of
Sparta: launcher pages, Play, back, the in-game menu in both languages,
checked from window captures. Not tested: Linux, Windows, Android, a
gamepad by hand, touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_apk.sh takes EXTRA_ASSETS, a folder whose contents go into the
APK's assets; the app unpacks the launcher's images from it on its first
start, beside the bundled font, where the launcher looks for an app's own.

The touch face buttons were spaced 1.45 radii from the diamond's centre,
so neighbours all but touched and a thumb pressed two. They are now a
little smaller and 1.9 radii out, leaving more than half a button between
them.

A screen driven by touch has no Esc: the hints for Back and for closing
the menu become buttons there, and the hints a finger does by touching
the row itself are left out. With a mouse, clicking those two hints makes
the request too.

Tested on a Moto g84 5G (Adreno 619, Android 15) with God of War: Ghost
of Sparta: the launcher with its art, the setup from the file picker,
play, the face buttons and the menu's Back and Resume by touch. Not
tested: other devices, the desktop click on hints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on a Moto g84 5G (Adreno 619) in God of War: Ghost of Sparta,
whose bloom draws into corners of one buffer and samples other corners of
it some thirty times a frame. The game ran at about 45 frames a second,
75% speed; with these changes it holds 55-58, 92-97%, in the same scene.

- A texture read from a render target copies only what it reads, with a
  margin for filtering, instead of the whole target
  (<prefix>_NO_PARTIAL_COPIES copies all of it).
- A target whose depth was never written neither loads nor stores depth
  in its passes, which halves what a pass moves on a tiled GPU; a draw
  that writes depth restarts the pass with depth kept
  (<prefix>_NO_DEPTHLESS_PASSES).
- Checking that guest memory under a framebuffer texture is unchanged
  compares only the words under the texture, straight from guest memory,
  and a range compared once in a frame is spot-checked after that
  (<prefix>_NO_FB_CHECK_CACHE compares every word every time). The full
  comparison was 4 ms a frame on the phone.
- The ignored GE commands are counted in a table rather than a map.
- <prefix>_TRACE_PASSES logs every render pass and target copy.
- A debuggable Android build reads <prefix>_* switches from env.txt in
  its storage, the only way to set them on a phone.

Checked on macOS: the scenes before and after match pixel for pixel where
two runs of one build do (runs differ from each other a little, the guest
clock starting from the wall clock). Not tested: other GPUs, other games.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When drawing every frame would slow the game, frame skipping leaves some
frames undrawn. Each flip measures what a frame took in real time, drawn
or skipped, against the emulated time it stands for (one vblank, two for
a game at 30 frames a second), and skips the share of frames that brings
the average within it, spread evenly and never two in a row.

A skipped frame runs the game and its display lists as ever, but draws
nothing into the buffers the game shows nor into those drawn in the last
two frames, so a buffer drawn once and kept (a reflection, say) is still
made; the pointers move past the dropped prims undecoded. Its flip shows
nothing new: the buffer flipped to holds a picture two frames old.

Video > Frame skip (Auto, on by default; Off) and <prefix>_FRAME_SKIP
turn it off; <prefix>_FRAME_SKIP_SHARE forces a share, to see it on a
machine that keeps up. The performance line counts skipped frames.

On a Moto g84 5G (Adreno 619) in God of War: Ghost of Sparta the game
now holds 95-100% speed in its heaviest scene, showing 44-58 frames a
second, where it ran at 75-97%; its cutscenes at 30 frames a second skip
nothing. On macOS nothing is skipped but the odd frame of a load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tiled GPU loads and stores a pass's render area, and every pass covered
its whole target. God of War: Ghost of Sparta draws its bloom chain into
corners of three buffers, about twenty passes a frame, each over 2-27%
of its target, with the scissor left at the whole screen.

A pass now begins over what its target was drawn in this frame and the
last: the union of each draw's reach, its scissor narrowed to the box
around a through-mode draw's vertices (a pixel's margin) or to the
viewport a transformed draw is clipped to. A draw outside the area begins
the pass again over the union, which happens in a target's first frames.
A target drawn for the first time takes all of it. <prefix>_NO_PASS_AREA
covers whole targets as before; <prefix>_TRACE_PASSES prints each area.

Checked on macOS by eye: the scenes match but for animation timing (runs
differ from one another). On a Moto g84 5G the player felt it smoother;
not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each list enqueue, stall update and sync, with the real time it took and
since the previous call. God of War: Ghost of Sparta builds a frame's
whole list, enqueues it once and syncs on it within a millisecond, so an
asynchronous GE would leave it little to overlap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A profile can list the disc ids of other releases with the same textures
(GameProfile::texture_pack_releases). Their packs import and load as the
game's own: a PPSSPP pack made for the North American God of War: Ghost of
Sparta keys the European release's textures alike, but was refused because
its folder is named UCUS98737 and its textures.ini lists no game. A pack
copied by hand into textures/<other release> is read too, when the game's
own folder is missing.

On Android, Import texture pack opens the system's folder picker, which
reaches Downloads and SD cards. The pack is found in the picked tree in the
layouts the desktop import knows, copied into a staging folder with a
progress bar and a cancel button, checked there, and put in place. The JNI
folder listing now reports file sizes for the progress.

GameProfile::default_volume sets the volume a new player starts with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… parts

The stencil test. The GE keeps the stencil in the framebuffer's alpha; the
renderer now keeps it in a stencil buffer beside the depth buffer (32-bit
float depth with 8-bit stencil, else 24-bit; without stencil only on a
device with neither, or with <prefix>_NO_STENCIL). Clears of alpha clear the
stencil, on targets that have used it, so targets that never do keep their
passes without depth. God of War: Ghost of Sparta marks surfaces with it and
draws its bloom only where it marked; without it the bloom covered the whole
screen.

Framebuffers inside another's area. A framebuffer pointer inside the area of
an existing target with the same row length and format is that target on the
PSP, drawn at an offset; it is now drawn into it, moved by its offset, instead
of into a target of its own. Ghost of Sparta draws each character's shadow
map 64 pixels apart in one buffer, blurs the row as one texture and projects
each map from its place: as separate targets the blur and the projections
read empty or stale pixels, which showed as black sheets and oversized
shadows. The checked guest words are read again at the first draw at such an
address in a frame, and frame skipping drops its draws with its target's.
<prefix>_NO_TARGET_ALIASES keeps a target for every address.

Clamped parts of a target. A texture read from part of a target, by a 3D draw
or a through draw whose coordinates leave it, is copied into an image of its
own so clamping and repeating work at the texture's edges, not the target's.
<prefix>_NO_FB_EXTRACTS reads from the target's copy as before.

GameProfile::frame_writeback (default on) lets a game that never reads its
own picture with the CPU skip copying the shown frame back into VRAM every
flip (<prefix>_NO_FRAME_WRITEBACK to try it). Target snapshots read guest
memory directly, which showed in phone profiles.

Traces: <prefix>_TRACE_STENCIL, <prefix>_TRACE_ALIASES, extracts and aliases
under <prefix>_TRACE_FB_TEXTURES, which now prints each distinct value an
ignored GE command is given (the first sixteen) instead of the first four.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GameProfile::start_skips_movies: while a movie the game plays through sceMpeg
has shown half a second, START ends it. The ring is emptied as the game fills
it and the stream calls report no data, so the game's own player runs to the
end of the file and moves on, as it does when a movie ends. Off by default;
<prefix>_NO_MOVIE_SKIP turns it off for a game that has it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Video setting, offered for games that draw 60 frames a second: every other
frame is skipped, so the game keeps its full speed and the picture shows 30,
with half the drawing work. Frame interpolation is off meanwhile, and the
Frame rate and Frame skip rows say why. Nothing is skipped while the game
makes 30 itself (movies, menus), which would leave 15. <prefix>_HALF_RATE.

On a Moto g84 (Adreno 619) God of War: Ghost of Sparta then holds 100% speed
in its heaviest early fight, where drawing every frame ran at 85-90%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A game may draw a mesh as many small prims with nothing between them but
the vertex and index pointers: God of War: Ghost of Sparta makes some 6500
prims a frame, nine vertices each, and the renderer did its whole per-draw
work for every one before merging them back into some 450 Vulkan draws.

GeState now keeps such prims together and hands them to the sink as one:
triangles, strips and fans become one indexed triangle list, in the order
the renderer expands them in, and unindexed sprites join as they are. A
command that may change how a prim draws ends the batch (pointers, BASE and
the bone matrices, applied as the vertices are decoded, do not), and so do a
block transfer, a patch, the end of the list and its stall. Raw draws (GPU
vertex decode) are sent alone. <prefix>_NO_DRAW_BATCH turns it off.

Ghost of Sparta: 6600 -> 790 draws a frame reach the renderer, 450 -> 145
Vulkan draws. On a Moto g84 (boat fight, x2, frame skip off) the game
thread's display list time drops from 8.7 to about 6 ms and the game runs at
100% speed, where it ran at 90-91%. Captures on macOS match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every word of an automatic unit is a dispatcher entry, so every
instruction is a block the switch can enter and the C++ compiler keeps
the whole guest state in memory between any two of them.

With --sparse-entries, blocks are the analysis' block starts plus every
branch and j target, and entries are only where control can arrive from
outside a block: analysis seeds (functions, data and relocated code
pointers), jal targets, the word after every call or link branch, branch
and j targets in another unit, the first word of each unit, and every
word the analysis never reached. Delay slots are neither.

A unit entered with no entry at that address calls the interpreter
(new CorpusHostApi::interpret, ABI version 2), which runs up to the next
registered address, instead of stopping with "invalid internal function
entry". Without the option the output is unchanged.

Ghost of Sparta: 1,040,000 entries -> 93,774; two units compiled with
clang -O2 for arm64 are 35-38% smaller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each unit entry took the AOT memory view by reference. Guest stores go
through a byte pointer, which may alias anything, so the RAM pointer and
limits were reloaded from the view after every store. The entry now
copies the view into a local whose address never escapes and passes the
shared one on to chained calls. Unit 0172 of Ghost of Sparta: 2756 of
these reloads -> 122.

--return-hints: a `jr $ra` in a function called by a same-unit jal (or
linking branch) first tests those callers' continuations, which are
labels, before the dispatcher's PC -> entry table and the indirect
branch every return in the unit shares. Up to 16 per return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CMake sets MSVC for clang-cl too, so the generated units and overlays got
/O2 /Ob0, which under clang-cl turns off inlining of everything not marked
always-inline. clang-cl copes with these units as clang does on the other
platforms; cl.exe keeps /Ob0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list modes acted as if the player confirmed the first entry, so a game
with save slots always saved to and loaded from the first one. They now
show the listed saves over the running game, with each save's icon, title,
detail and date (and, for saving, a new save first); the dialog stays
VISIBLE until the player chooses, and the load, save or delete happens then.
Saving over a save or deleting one asks first; backing out reports a cancel.
The cursor starts where the request's focus field says. Without an
interface the list modes behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The face button that answered the game's own "save?" prompt could still be
down when the list opened, and the list took it for back on its first frame;
the pad now counts only once every face button is released, as for
navigation. Each save shows its slot number and its whole detail, and the
footer how many saves there are. After the choice the panel says how it went
(saving, saved or could not save; loading; deleting) for a moment while the
frame stays held, which also covers the half-drawn frames the game shows as
it comes back from the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The texture mapping registers (0xC0 TMAP, 0xC1 TEXSHADELS) were ignored, so
every transformed draw took its vertices' own UVs. God of War: Ghost of
Sparta projects its characters' shadow maps onto the floor with the texture
matrix (mode 1, position source), and the shadows came out far from the
characters and much too large. Mode 1 now multiplies the projection source
(model-space position, UV, or normal) by the texture matrix and divides by q
per pixel; mode 2 maps the world-space normal against the two lights
TEXSHADELS names. The matrix and the mode reach the vertex shader in the
object block, which texgen draws now write as lit draws do.
<prefix>_NO_TEXGEN uses the vertex UVs, as before.

Through-mode tiles with the clamp wrap mode also stop sampling at the edge
texels' centres, as the GE does, when read from a render target's copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GE ignores lighting in through mode, but the renderer gave a vertex
without a colour the material colour only when the lighting enable was off,
and the last 3D draw usually leaves it on. Such through sprites came out
white. God of War: Ghost of Sparta clears its shadow maps with them, so each
map got a white ring that showed as a dark rectangle around every
character's shadow; stray white lines along edges went with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A window scale too large for the display (x4 is 1920x1088 on a 1080p screen)
made a window that covered the whole screen and looked full screen. The
scale is now cut to what fits the display's usable area, at start and when
the menu changes it, and a resized window is centred.

The launcher plays launcher/music.mp3 from its folders (the player's own
file; nothing ships one), looped, at a third of the game's volume, and not
when the audio is muted. MP3 decoding is dr_mp3 (public domain or MIT-0),
since the bundled FFmpeg on macOS and Android has no MP3 decoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mod can put NAME.264 (raw H.264) in mods/movies of the data folder: when
the game plays its movie NAME, each picture it decodes is replaced, on the
GPU, by the same picture of the mod's movie, scaled to the internal
resolution; the game's sound and anything it draws on top (subtitles) stay.
The game's own pictures still go to guest memory. The replacement is read a
piece at a time and decoded on frame threads (AvcDecoder::open(threads)),
so a long movie at 1920x1088 costs little memory and holds 30 frames a
second.

Games that open files by sector (sce_lbn) are followed too: the disc image
names the file at a sector (IsoImage::path_at), and the movie player looks
the mod up when a movie starts.

A Mods page in the menu and the launcher turns replacement movies on or off
(video.movie_mods, on by default), opens the folder and lists the movies
found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Texture packs live in mods/textures of the data folder (an older textures
  folder is moved there once) and their rows move from Video to Mods.
- START skipping a movie is a setting (video.movie_skip, on by default) on
  the Mods page, for games whose profile allows it.
- The Mods page shows how many remastered movies were found, not a list.
- Opening a folder goes through open_folder only, and its file:// URL is
  right for a Windows path (file:///C:/...): the buttons did nothing there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- FileBrowser::Options::system_first: the system's file or folder dialog
  opens at once and its answer is the browser's; cancelling it cancels. The
  browser waits with "Open the window again", "Browse here instead" and
  "Cancel", and draws its own list only when asked or when there is no
  dialog (gamescope, Android). The texture pack import and the save import,
  export and backup use it.
- The texture pack review: a heading that says whether the pack can be
  installed, the folder, and one line with its size and the release it was
  made for; the install buttons right after; the pack in use now at the
  end, only when there is one. Its texts are translatable, and the paths are
  mods/textures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file in mods/files of the data folder stands in for the disc's file at the
same path, matched without regard to case (the disc names its files in
capitals; Android and Linux tell cases apart). Games that open their files by
sector (sce_lbn) are covered too: a sector open that spans exactly one disc
file, which IsoImage::path_at names, is that file. The replacement is a
read-only host file that answers as the disc's would: its reads count as disc
reads for fast loading, ioctl still gives the disc file's first sector, and
sceIoGetstat and sceIoDopen give the replacement's size. Each replaced file is
logged once.

The Mods page gets a "Replaced files" row (setting mods.files, on by default,
<prefix>_FILE_MODS) with the number found and a button that opens the folder.

Also: save_chooser.cpp did not build with libc++, whose file times are a
128-bit count that std::to_string has no overload for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shutdown() destroyed the main descriptor pool before the render targets,
framebuffer extracts, the held frame and the interpolation targets, each of
which then freed its descriptor sets from the destroyed pool. On MoltenVK that
corrupted the heap: the program crashed as it quit (Trace/BPT trap, or a
SIGSEGV in MVKDescriptorPool::freeDescriptorSets with guard malloc), now and
then, more or less often as other allocations moved the heap around. The pool
now goes after all of them. Ten scripted quits in a row on macOS (M4,
MoltenVK) exit cleanly, where before up to all of them crashed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new row on the Video page, Antialiasing (Off, FXAA; setting
video.antialiasing, <prefix>_ANTIALIASING=off|fxaa), off by default. With FXAA
the game's frame is copied into a sampled image at the internal resolution,
smoothed into a second one by host/gpu/shaders/fxaa.frag, and that is scaled
to the window in its place, so the bars, the level horizon, the overlay and
the interface are untouched. The shader is this project's own: a contrast
test on luma, the edge's direction from second differences, a walk along the
edge both ways, and a resample moved across it by how near the pixel is to
the closer end, with a blend for features thinner than a pixel.

One pair of images serves every frame in flight (the barriers around each use
order it after the last); it is made when FXAA is first on and again when the
internal resolution changes. The level horizon's pipeline set-up is shared
with it (create_picture_pipeline).

Tested on macOS (M4, MoltenVK) with Ghost of Sparta: the same cutscene frame
with FXAA off and on at x1 differs only along edges (the staircase along
Kratos' shoulder is smoothed), with frame interpolation on as well. GPU cost
not measured on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A game that sizes its files from the directory listing (sceIoDopen, which
gives a replaced file's size) then opens them by sector asking for the
replacement's length, not the disc's. Ghost of Sparta does: a sector open of
that length is now the replaced file too. Tested by putting the French
FRONTEND.BIN (11348 bytes) in place of the English one (10308): the title
menu shows in French.

<prefix>_SYSTEM_LANGUAGE (0 Japanese, 1 English, 2 French, ...) overrides the
profile's system language, for a game with several languages on its disc.
Ghost of Sparta then reads DATA/FRENCH and uses a font with French accents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in place

GeState::execute read every command word through GuestMemory::contains and
an out-of-line load32. It now reads them through a host pointer to a 1 KiB
window of the list, looked up again when the list leaves it.
<prefix>_NO_LIST_WINDOW restores the per-word reads.

triangle_indices wrote through push_back, and a batched prim's indices were
then copied again one by one to add the batch's base. append_triangle_indices
sizes the output once and writes the indices with the base added; an
unindexed prim with every position decoded skips the lookup and the clamp.

Measured on a Moto g84 (Snapdragon 695), God of War: Chains of Olympus, 15 s
of simpleperf in a fight, absolute cycles on the game thread: list reads
0.83 G -> 0.17 G, index building (triangle_indices + send_draw itself)
1.39 G -> 0.85 G, in a run with more draws than the baseline. Render tests
pass, with a new test for the appended form; captures on macOS look right.
Not verified: Ghost of Sparta, Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MHunterG
MHunterG self-requested a review October 19, 2026 06:03
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.

2 participants