Skip to content

Hi-res texture packs on Android (opt-in, mobile-safe) + in-place .zip reading#235

Merged
JRickey merged 6 commits into
mainfrom
agent/hires-android
Jun 17, 2026
Merged

Hi-res texture packs on Android (opt-in, mobile-safe) + in-place .zip reading#235
JRickey merged 6 commits into
mainfrom
agent/hires-android

Conversation

@JRickey

@JRickey JRickey commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Enables the hi-res texture pack on Android — previously compiled out — with mobile-safe limits, lets a pack be read straight from a .zip (no extraction) on every platform, and adds a SAF importer so a phone user can install a pack under scoped storage.

What's in it (6 commits, layered)

  1. Engine — platform-scaled decoded-RGBA8 LRU budget (512 MB desktop / 128 MB Android, runtime-settable via gHiResTextures.CacheBudgetMB); per-texture upscale cap on mobile; reads .zip packs in mods/ in place via libzip + stbi_load_from_memory; the scan is wrapped so a bad mods/ disables the pack instead of crashing boot; fixed the stale "256 MB" docstring.
  2. Runtime wiringgHiResTextures.Enabled defaults off/opt-in on Android (a dropped-in pack can't silently allocate a large working set); menu default + dev dump-tooling gated off touch UI.
  3. Build — compile port/hires/ on Android, define PORT_HIRES_ENABLED for Android US (JP still excluded — US-only hash inputs).
  4. Android importerPackImporter copies a SAF-picked zip into the app-owned mods/ (the only way the native scanner can read it under scoped storage), via an "Import texture pack" launcher shortcut reusing BootActivity's flow. No double-copy, .part→rename, partial cleanup, ENOSPC fail-fast, filename sanitization, ZIP validation, size cap. Import runs before SDL starts → no rescan race.
  5. Review pass 1 — zip-member size cap + bad_alloc guard (untrusted-pack OOM/crash); LruCache::Clear() on re-init; atomic Files.move publish + try/finally .part cleanup; debug-gated the dev_pack extra.
  6. Review pass 2BootActivity configChanges so a config change during import can't recreate it and strand the result; degenerate .zip filename guard; log on corrupt-zip zip_get_num_entries.

Validation

  • Desktop: a single pack.zip in mods/ → 4512 textures indexed from inside it, ~95% lookup hit rate, 0 decode-fails, hi-res rendering confirmed.
  • Android emulator (API 34, arm64): SAF import writes an app-owned mods/devpack.zip; native scan indexes 4512 (no "Permission denied"); lookups hit ~87%; decoded-RGBA8 LRU pinned at 128 MB under the full 219 MB pack with no low-memory kill; stress run stable.
  • Two rounds of adversarial review (Codex + direct); all real findings fixed, init-race / LRU tail-invariant / entry-lifetime confirmed sound.

Builds clean on desktop and the Android NDK toolchain. Superproject-only commits (the libultraship bump already rode in with the viewport fix on main).

🤖 Generated with Claude Code

JRickey and others added 6 commits June 17, 2026 00:52
Rework the HiResPack engine so a pack is survivable on memory-tight
platforms and can be read straight out of a .zip:

- Decoded-RGBA8 LRU budget is platform-scaled and runtime-settable
  (gHiResTextures.CacheBudgetMB): 512 MB desktop / 128 MB Android default,
  applied in Init() once CVars load. LruCache gains SetBudget/EvictToBudget.
- Per-texture upscale cap (kMaxPackTexels, mobile only) rejects a single
  oversize PNG so it can't blow the budget or the uncompressed GPU upload --
  the LRU never evicts its just-inserted tail, so a lone over-budget entry
  would otherwise exceed the cap outright.
- mods/ indexes .zip packs in place via libzip + stbi_load_from_memory (no
  extraction); loose PNGs and zip members share the same hash grammar, only
  the decode source differs. This is the distributed pack format.
- Wrap the mods/ scan so a filesystem error (locked subdir, symlink loop,
  file removed mid-walk) disables the pack instead of crashing boot.
- Fix the stale "~256 MB" budget figure in the Lookup() docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- gHiResTextures.Enabled defaults via kHiResEnabledDefault: on for desktop,
  OFF/opt-in on Android so a dropped-in pack can't silently allocate a large
  decoded working set on a phone. Hook and menu share the constant so the
  runtime default and the menu widget agree.
- PortMenu: platform-default checkbox, an Android memory-cost note, and the
  dev dump-tooling (source/miss dumps) gated off touch UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop excluding port/hires/ on Android and define PORT_HIRES_ENABLED for
Android US too, so the opt-in, mobile-budgeted pack compiles into
libmain.so. JP still drops the pack entirely (US-only hash inputs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under scoped storage the native HiResPack scanner can only read files the
app itself owns; a pack adb-pushed or dropped by a file manager into
Android/data/<pkg>/ is owned by another uid and the recursive scan fails
with "Permission denied". So the app copies the user-picked zip into its
own mods/ (app-owned -> readable), reusing BootActivity's SAF flow.

- PackImporter: streams the picked content:// URI straight to
  mods/<name>.zip (no cacheDir double-copy -- packs are ~200 MB) via a
  .part temp renamed only after it validates as a real ZIP. Every failure
  path deletes the partial; a full disk fails fast; the SAF display name
  is sanitized to a safe basename (no path traversal); a 1 GB cap rejects
  absurd input. Carries the v1.3 Android-pass discipline: clean up large
  staged files, validate untrusted input, fail fast on write error.
- BootActivity: an "Import texture pack" launcher shortcut routes here
  with ssb64.import_pack=true -> SAF picker -> background import -> Toast
  -> normal route. Import runs BEFORE the SDL game starts, so HiResPack's
  scan sees the new pack single-threaded at boot -- no rescan race against
  the renderer.
- Dev shortcut ssb64.dev_pack=<abs path> mirrors ssb64.dev_rom for
  scripted install without the picker UI.

Verified on the API 34 arm64 emulator: import writes an app-owned
mods/devpack.zip, the native scan indexes 4512 textures from it (no
Permission denied), lookups hit ~87%, and the decoded-RGBA8 LRU stays
pinned at the 128 MB Android budget under the full 219 MB pack with no
low-memory kill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Findings from an adversarial pass over the branch:

- [high] ReadZipMember trusted zip_stat's declared member size and did
  out.resize(st.size) with no bound or bad_alloc handling -- a hostile or
  corrupt downloaded pack with a huge declared entry crashed the game via
  uncaught std::bad_alloc. Now reject size 0 / > 256 MB (real PNGs are
  ~25 MB; stays under INT_MAX so the later (int) cast for stbi can't
  overflow) and catch bad_alloc.
- [low] Init() cleared gIndex/gOpenZips but not gLru -- a future re-scan
  with a changed pack could serve a stale decoded texture on an LRU hit.
  Add LruCache::Clear() and call it in Init().
- [low] PackImporter published with delete-then-rename (a window leaving
  mods/ with neither old nor new pack on rename failure) and could orphan
  the ~200 MB .part if ZipFile validation threw an Error. Now publish via
  atomic Files.move(REPLACE_EXISTING) inside a published-flag try/finally
  that deletes the temp on any non-success exit.
- [low] The ssb64.dev_pack BootActivity extra is now gated behind
  BuildConfig.DEBUG so a release build's exported launcher activity can't
  be driven by another app into an arbitrary-path import (confused deputy).

Re-verified on the emulator: import still writes an app-owned
mods/devpack.zip, the scan indexes 4512 textures with zero spurious size
rejections, and lookups hit ~86%. The review also confirmed the init-race
is absent and the LRU tail-eviction invariant, budget floor, and
entry-lifetime are sound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ards)

A deeper review of the branch surfaced three items beyond the first pass:

- [medium] BootActivity runs the multi-second pack import on a background
  thread but had no configChanges, so a config change in that window
  (multi-window resize / dark mode / font scale; rotation is already
  locked landscape) recreates the Activity and the worker posts its result
  to the dead instance, leaving the app stuck on the picker. Give
  BootActivity a configChanges list so it absorbs those without recreation.
  Also covers the identical pre-existing ROM-picker case.
- [low] safeZipName: a SAF display name of " .zip" trims to ".zip" and
  would land as a hidden file; reset that to "pack.zip".
- [low] ScanZip: log when zip_get_num_entries returns -1 (corrupt central
  directory) instead of silently treating the pack as empty.

The same review confirmed the earlier fixes hold and that the remaining
flagged items are non-bugs (log-before-erase, tail-eviction invariant,
zero-budget floor, the redundant-but-safe post-Insert Get, boot-only init
with no concurrent Lookup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@JRickey JRickey merged commit 9e1164c into main Jun 17, 2026
3 checks passed
@JRickey JRickey deleted the agent/hires-android branch June 17, 2026 14:26
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