This commit is contained in:
Guilherme committed 2026-10-27 11:28:10 -03:00
commit a5c24ecc49
12 files changed
+388 -12

No files matched your search

+1
View File
@@ -164,6 +164,7 @@ The screenshots and the performance chart are at the top of this page.
- **60 FPS and unlocked frame rate (experimental):** launcher → Video → "Frame rate (experimental)", or `TLG_FPS=30|60|unlocked`. The default is still 30 FPS, as on PS4. Details in [`docs/60fps.md`](docs/60fps.md) (in Portuguese, [#5](https://github.com/HayatoG/tlg_pc/issues/5)).
- **Image filters:** SMAA anti-aliasing, RCAS (FSR 1) or CAS (AMD) sharpening and colour adjustments (brightness, contrast, saturation, gamma), in the launcher and live in the in-game menu (Insert → Image) ([#2](https://github.com/HayatoG/tlg_pc/issues/2)).
- **Mouse and keyboard:** the mouse turns the camera (sensitivity and inverted Y in the launcher), and keys and mouse buttons can be remapped ([#8](https://github.com/HayatoG/tlg_pc/issues/8)).
- **Japanese text (experimental):** on EU CUSA03745 update 01.03, choose **日本語 (EU 1.03 text fallback)** in Game language. The port handles the embedded-asset fallback without moving or deleting archives. See [setup and limitations](docs/japanese.md); this is not Japanese-region disc support.
- **Motion sensor:** the DualShock 4/DualSense gyro works as is. On controllers without one, a tap on Share (or L3+R3) toggles "sensor mode": the left stick tilts the controller and cross/A shakes it. On the keyboard, T/G tilt and Y shakes.
- **PS4-like brightness and lighting:** the game's auto exposure now reads the right value from the GPU, so outdoor scenes no longer blow out to white (`TLG_READBACK_LINEAR=1`, on by default; `0` restores the old behaviour) ([#38](https://github.com/HayatoG/tlg_pc/issues/38), [#40](https://github.com/HayatoG/tlg_pc/issues/40)).
- **Direct memory access (shaders):** new launcher option (Graphics → GPU), **off by default** (`TLG_DIRECT_MEMORY_ACCESS=1`). Shaders read constants straight from the game's memory: fixes some broken scenes, but the game runs slower. Its shaders live in a cache of their own and are compiled again the first time.
+96
View File
@@ -0,0 +1,96 @@
# Japanese text on the EU build
## What this supports
The verified setup is **EU CUSA03745 with update 01.03 installed/merged into the game
folder**, displaying Japanese assets already included in the shared boot archives.
This is **not support for the Japanese-region disc executable**. US, Korean, Chinese,
other updates and modified asset sets have not been validated for this fallback.
Japanese menus and gameplay prompts have been seen in-game; full-game text, narration
and audio coverage have **not** been exhaustively verified.
The launcher option and automatic handling below require **both a launcher and native
runtime built with this change**. Do not copy only the new launcher over an old runtime:
the old runtime will not know how to hide the French packs.
## Setup — no archive editing or moving required
1. Use your own EU game dump with its **01.03 update applied to the same game folder**.
An update PKG merely stored beside the base game is not an installed update.
2. In the launcher, select the folder containing `eboot.bin` and `sce_sys/param.sfo`.
Keep the normal game files, including `trico_boot.psarc`, `trico_bootp.psarc`,
`trico_frf.psarc` and `trico_frfp.psarc`. Do not rename, delete or edit them.
3. In **Game language**, choose **日本語 (EU 1.03 text fallback)**, then **Play**.
Both the Qt and classic Tk launchers expose this choice for the verified asset layout.
The port automatically uses the appropriate internal language id and hides the two
French localization packs **only from the game's view for that launch**. Their bytes
and names on disk remain unchanged. No extra language download, save conversion, package
modification, region spoof or custom launcher is required.
To return to French, select **Français** and launch again. Other languages also disable
the temporary mask automatically. Japanese text is opt-in: **Automatic** does not quietly
select this experimental fallback just because the operating system is Japanese.
This does not swap controller buttons. Japanese-style O-confirm/X-back is a separate
menu preference/change ([PR #140](https://github.com/HayatoG/tlg_pc/pull/140)); selecting
Japanese text alone does not enable it.
## Troubleshooting
- **The option is missing:** verify that the launcher/runtime contain this change, the
game folder is EU `CUSA03745`, `APP_VER` is `01.03`, and both boot archives are present.
The launcher does not advertise this workaround for unverified regions or versions.
- **A previously saved Japanese choice is shown for a different game folder:** that
choice is preserved, but the fallback is disabled and the game uses English. The Qt
launcher displays an explanation; this does not enable Japanese-region disc support.
- **It still shows French:** ensure the native runtime was rebuilt too. Remove a manual
`TLG_LANGUAGE` override that conflicts with the launcher selection. The normal launch
log should report `Japanese text fallback: French root archives hidden from guest`.
- **It shows English with a forced language id:** setting `TLG_LANGUAGE=0` is not this
workaround; that fell back to English in the tested EU build. Let the launcher select
the internal id instead.
- **You already moved the French packs for the old workaround:** the option also works
without them, but French cannot be restored by changing the language until you put
your original packs back. Never replace game archives from an unknown source.
Saves stay under the same EU title id and user folder. This feature does not rewrite
saves, game archives, metadata or the game executable. Backups before each launch still
follow the port's normal save-backup policy.
## Why the code is needed
Japanese paths remain in the shared boot archives, but the EU launcher's normal language
table does not expose them. The working fallback requests the internal French language
id (`2`) while making the France-French packs unavailable. With those packs absent, the
game falls back to its embedded Japanese assets. Simply requesting native Japanese id
`0` did not produce the same result.
Previously a player had to select the misleading **French** option and manually move
`trico_frf.psarc` and `trico_frfp.psarc`, then restore them to play in French. The new
launcher explicitly names the Japanese fallback and sets `TLG_JAPANESE_FALLBACK=1`
together with the internal id. `runtime_file.c` returns `ENOENT` for those two root
archives in `/app0`, `/hostapp` and equivalent relative paths, and omits them from guest
directory listings. It does not hide similarly named files in saves or nested folders,
other language packs, or any host files from other processes.
Switching away clears the mask, even if the launcher's parent environment contained a
stale value. An explicit incompatible `TLG_LANGUAGE` override wins and disables the mask;
remove that debugging override to use the launcher option. Direct native-runtime users
must supply both `TLG_LANGUAGE=2` and `TLG_JAPANESE_FALLBACK=1` on the verified build;
the native flag alone does not identify or validate a disc region.
## Validation and limits
The earlier manual archive-omission setup was tested in-game on EU 01.03 and produced
Japanese menus/gameplay prompts. Its actual kernel opens were `/app0/trico_frf.psarc`
and `/app0/trico_frfp.psarc`, the paths now masked by this change.
Automated C tests exercise real guest open/stat/list behavior with French packs still
present on disk, check relative and hostapp aliases, ensure saves/nested files and other
packs remain readable, and verify French is readable again when the option is off.
Python tests cover version/region/layout gating, UI choices, language-id/mask pairing,
normal French, stale environment cleanup, explicit overrides and non-mutation.
A full newly rebuilt launcher/runtime still needs end-to-end in-game regression testing.
The previous manual proof is not presented as testing of a rebuilt version of this PR.
+2
View File
@@ -104,6 +104,8 @@ if [[ ${1:-} == --test ]]; then
out/runtime-test
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_file_mods.c -o out/file-mods-test
out/file-mods-test
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_japanese_language.c -o out/japanese-language-test
out/japanese-language-test
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread "${includes[@]}" -I. -Isrc tests/test_sema.c "${runtime[@]}" out/libatrac9.a -lm "${gpu[@]}" "${libraries[@]}" -o out/sema-test
out/sema-test
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_rwlock_rp.c -o out/rwlock-rp-test
+4
View File
@@ -625,6 +625,10 @@ class Backend(QObject):
@Property(str, notify=gameLanguageChanged)
def gameLanguageNow(self):
"""'Automatic: Português (Brasil)' while 'auto' is active, else empty."""
if self.settings.get('game_language') == 'ja':
return self.t_('game_language_japanese_fallback' if
tlg_run.japanese_fallback_available(self.settings.get('game_dir')) else
'game_language_japanese_unavailable')
if self.settings.get('game_language', 'auto') in tlg_run.GAME_LANGUAGES:
return ''
return self.t_('game_language_now', name=logic.GAME_LANGUAGE_NAMES[tlg_run.resolve_game_language(self.settings)])
+2
View File
@@ -1,6 +1,8 @@
{
"game_language_label": {"en": "Game language", "pt": "Idioma do jogo"},
"game_language_auto": {"en": "Automatic (system language)", "pt": "Automático (idioma do sistema)"},
"game_language_japanese_fallback": {"en": "Experimental Japanese text from embedded EU 1.03 assets. French archives stay on disk and are hidden only from this launch. Full-game audio/text coverage is not yet verified.", "pt": "Texto japonês experimental dos arquivos europeus 1.03. Os arquivos franceses ficam no disco e são ocultados apenas nesta execução. A cobertura de áudio/texto do jogo inteiro ainda não foi verificada."},
"game_language_japanese_unavailable": {"en": "Japanese text fallback requires EU CUSA03745 update 01.03 and both boot archives. This game folder will use English instead. This does not add Japanese-region disc support.", "pt": "O texto japonês exige CUSA03745 europeu com atualização 01.03 e os dois arquivos boot. Esta pasta usará inglês. Isso não adiciona suporte à edição japonesa do disco."},
"game_language_now": {"en": "Automatic: {name}", "pt": "Automático: {name}"},
"game_language_hint": {"en": "Applies on the next start. Works if your game files have that language.", "pt": "Vale na próxima partida. Funciona se os arquivos do seu jogo tiverem esse idioma."},
"nav_play": {"en": "Play", "pt": "Jogar"},
+1 -1
View File
@@ -30,7 +30,7 @@ GAME_LANGUAGE_NAMES = {'en': 'English', 'en_GB': 'English (UK)', 'pt_BR': 'Portu
'fr_CA': 'Français (Canada)', 'fr': 'Français', 'de': 'Deutsch', 'it': 'Italiano',
'nl': 'Nederlands', 'pl': 'Polski', 'tr': 'Türkçe', 'ru': 'Русский', 'cs': 'Čeština',
'hu': 'Magyar', 'el': 'Ελληνικά', 'ro': 'Română', 'fi': 'Suomi', 'sv': 'Svenska', 'da': 'Dansk',
'no': 'Norsk', 'ar': 'العربية', 'ja': '日本語', 'ko': '한국어', 'zh_TW': '繁體中文',
'no': 'Norsk', 'ar': 'العربية', 'ja': tlg_run.JAPANESE_FALLBACK_LABEL, 'ko': '한국어', 'zh_TW': '繁體中文',
'zh_CN': '简体中文', 'th': 'ไทย', 'vi': 'Tiếng Việt', 'id': 'Bahasa Indonesia'}
+3 -2
View File
@@ -189,7 +189,8 @@ COFFEE_URL = 'https://buymeacoffee.com/hayatog'
CHANNEL_URL = 'https://www.youtube.com/@hayatog-dev'
DISCORD_URL = 'https://discord.gg/UMkdMYZh9g'
SPONSOR_URL = 'https://github.com/sponsors/HayatoG'
GAME_LANGUAGE_NAMES = {'en': 'English', 'fr': 'Français', 'es_ES': 'Español (España)',
GAME_LANGUAGE_NAMES = {'ja': tlg_run.JAPANESE_FALLBACK_LABEL,
'en': 'English', 'fr': 'Français', 'es_ES': 'Español (España)',
'de': 'Deutsch', 'it': 'Italiano', 'nl': 'Nederlands',
'pt_PT': 'Português (Portugal)', 'ru': 'Русский', 'fi': 'Suomi',
'sv': 'Svenska', 'da': 'Dansk', 'no': 'Norsk', 'pl': 'Polski',
@@ -527,7 +528,7 @@ class Launcher:
"""Reload the combo for the game folder: another CUSA ships another set of languages."""
if getattr(self, 'game_lang_box', None) is None:
return
values = list(tlg_run.game_languages(self.settings.get('game_dir')))
values = [key for key in tlg_run.game_language_choices(self.settings) if key != 'auto']
self.game_lang_values = values
self.game_lang_box.configure(values=[GAME_LANGUAGE_NAMES.get(v, v) for v in values])
current = self.settings.get('game_language')
+38 -4
View File
@@ -17,6 +17,7 @@ import json
import os
from pathlib import Path
import re
import struct
import subprocess
import sys
import time
@@ -85,8 +86,23 @@ def default_language(game_dir=''):
return DEFAULT_LANGUAGE_BY_TITLE.get(title, 'es_LA')
# Every language key either disc has (the Qt launcher's names table covers them all).
GAME_LANGUAGES = {**LANGUAGES_US, **LANGUAGES_EU, **LANGUAGES_ASIA}
JAPANESE_FALLBACK_LABEL = '日本語 (EU 1.03 text fallback)'
def japanese_fallback_available(game_dir):
"""Only the verified EU 01.03 asset layout; not Japanese-region disc support."""
if not game_dir or shader_cache.title_id(game_dir) != 'CUSA03745':
return False
try:
return (mod_patches.game_app_version(game_dir) == '01.03' and
all((Path(game_dir) / name).is_file()
for name in ('trico_boot.psarc', 'trico_bootp.psarc')))
except (OSError, ValueError, struct.error):
return False
# 'ja' is an explicit embedded-asset fallback, not native engine language 0.
GAME_LANGUAGES = {**LANGUAGES_US, **LANGUAGES_EU, **LANGUAGES_ASIA, 'ja': 2}
# The closest choice when the system's language is missing from the disc.
_LANGUAGE_SIBLING = {'pt_BR': 'pt_PT', 'pt_PT': 'pt_BR', 'es_LA': 'es_ES', 'es_ES': 'es_LA',
'fr_CA': 'fr', 'fr': 'fr_CA'}
@@ -127,6 +143,8 @@ def game_language_choices(settings):
"""The game language box of the Qt launcher: 'auto', the languages the disc has and the saved
choice even when the disc lacks it (never changed behind the player's back)."""
keys = ['auto'] + list(game_languages(settings.get('game_dir', '')))
if japanese_fallback_available(settings.get('game_dir', '')):
keys.append('ja')
choice = settings.get('game_language', 'auto')
return keys + [choice] if choice in GAME_LANGUAGES and choice not in keys else keys
@@ -136,6 +154,8 @@ def resolve_game_language(settings, environ=None):
system (or its closest variant); anything else is English, as before."""
table = game_languages(settings.get('game_dir', ''))
choice = settings.get('game_language', 'auto')
if choice == 'ja' and japanese_fallback_available(settings.get('game_dir', '')):
return 'ja'
if choice in table:
return choice
if choice == 'auto':
@@ -145,6 +165,21 @@ def resolve_game_language(settings, environ=None):
return key
return 'en'
def game_language_environment(settings, environ=None):
"""Select the internal French id plus a non-destructive guest archive mask for Japanese.
Explicit engine-language overrides still win; a different override disables the mask.
Never inherit a stale mask from a previous launch when another language is selected.
"""
environ = os.environ if environ is None else environ
language = resolve_game_language(settings, environ)
engine_id = 2 if language == 'ja' else game_languages(settings.get('game_dir')).get(language, 1)
selected = environ.get('TLG_LANGUAGE', str(engine_id))
return {'TLG_LANGUAGE': selected,
'TLG_JAPANESE_FALLBACK': '1' if language == 'ja' and selected == '2' else '0'}
# TLG defaults (see README "Opções" and the reasons in each comment).
DEFAULT_ENV = {
'TLG_PIPELINE_BINARIES': '0', # VK_KHR_pipeline_binary crashes on the NVIDIA 595 driver
@@ -633,8 +668,7 @@ def build_env(settings, extra=None):
env['TLG_MENU_CONFIRM'] = menu_confirm(settings)
env['TLG_STICK_DEADZONE'] = os.environ.get(
'TLG_STICK_DEADZONE', f"{int(settings.get('stick_deadzone', 10)) / 100:.2f}")
env['TLG_LANGUAGE'] = os.environ.get('TLG_LANGUAGE', str(
game_languages(settings.get('game_dir')).get(resolve_game_language(settings), 1)))
env.update(game_language_environment(settings))
if settings.get('ui_language', 'auto') != 'auto':
env['TLG_UI_LANG'] = settings['ui_language']
# Without the console module the port's own mixer (runtime_ngs2.c) plays the audio.
+6 -2
View File
@@ -40,7 +40,9 @@ Tudo vem do **seu** PS4 (GoldHEN + Itemzflow). Nada disso vai no download. Use *
3. Abra **The Last Guardian** (`The Last Guardian.exe` no Windows): é o launcher novo (Qt), com telas de Gráficos, Controles, Correções, Saves, Mods, Suporte e Atualizações. O launcher antigo continua ao lado como **The Last Guardian (classic)**. Por padrão o launcher fecha quando o jogo abre e volta quando o jogo fecha (dá para mudar em Gráficos → Áudio e launcher).
- No Windows, se aparecer "O Windows protegeu o computador", clique em *Mais informações* e depois em *Executar assim mesmo*. O programa não é assinado digitalmente.
4. Em **Pasta do jogo**, escolha a pasta que tem o `eboot.bin`.
5. Escolha o idioma do jogo (inglês, português, espanhol ou francês) e clique em **Jogar**. A primeira partida demora um pouco mais.
5. Escolha um dos idiomas oferecidos para sua pasta do jogo e clique em **Jogar**. A primeira partida demora um pouco mais.
**Texto japonês experimental (EU 01.03):** com CUSA03745 e a atualização 01.03 aplicada na mesma pasta, escolha **日本語 (EU 1.03 text fallback)**. Não mova nem apague os arquivos franceses: o port os oculta apenas da visão do jogo nesta execução. Para voltar ao francês, escolha Français e inicie novamente. É necessário um launcher e runtime que incluam esse recurso; isso não adiciona suporte ao executável da edição japonesa do disco. Menus e dicas de jogo em japonês foram verificados; áudio/texto do jogo inteiro ainda não foram testados. Os botões de controle não são trocados.
No Linux, `./install_desktop.sh` adiciona o jogo ao menu de aplicativos.
@@ -119,7 +121,9 @@ Everything comes from **your own** PS4 (GoldHEN + Itemzflow). None of it is in t
3. Open **The Last Guardian** (`The Last Guardian.exe` on Windows): the new (Qt) launcher, with Graphics, Controls, Fixes, Saves, Mods, Support and Updates screens. The old launcher is still next to it as **The Last Guardian (classic)**. By default the launcher closes when the game starts and comes back when the game closes (change it in Graphics → Audio and launcher).
- On Windows, if "Windows protected your PC" appears, click *More info*, then *Run anyway*. The program is not digitally signed.
4. Under **Game folder**, choose the folder that has `eboot.bin`.
5. Pick the game language (English, Portuguese, Spanish or French) and click **Play**. The first start takes a little longer.
5. Pick one of the languages offered for your game folder and click **Play**. The first start takes a little longer.
**Experimental Japanese text (EU 01.03):** with CUSA03745 and update 01.03 applied to the same folder, select **日本語 (EU 1.03 text fallback)**. Do not move or delete the French archives: the port hides them only from the game's view for that launch. To return to French, select Français and launch again. Both launcher and runtime must contain this feature; it does not add Japanese-region disc executable support. Japanese menus and gameplay prompts were verified, not full-game audio/text coverage. Controller buttons are not swapped.
On Linux, `./install_desktop.sh` adds the game to the applications menu.
+38 -3
View File
@@ -90,6 +90,9 @@ void runtime_file_configure(const char *app0,const char *user) {
snprintf(user_root,sizeof(user_root),"%s",user);
runtime_file_mount("/app0",app0);
runtime_file_mount("/hostapp",app0);
const char *japanese=getenv("TLG_JAPANESE_FALLBACK");
if (japanese && !strcmp(japanese,"1"))
puts("Runtime: Japanese text fallback: French root archives hidden from guest; host files unchanged");
const char *writable[]={"temp0","download0","data"};
mkdir(user,0755);
for (int i=0;i<3;++i) {
@@ -99,6 +102,34 @@ void runtime_file_configure(const char *app0,const char *user) {
runtime_file_mount(guest,path);
}
}
/* Opt-in EU Japanese-text fallback: hide only the root French localization archives
* from the guest. Never move/delete host files or affect saves/other language packs. */
static int language_archive_name(const char *name,const char *expected) {
for (;*name && *expected;++name,++expected) {
unsigned char c=(unsigned char)*name;
if (c>='A' && c<='Z') c+=(unsigned char)('a'-'A');
if (c!=(unsigned char)*expected) return 0;
}
return !*name && !*expected;
}
static int japanese_hidden_archive(const char *guest) {
const char *enabled=getenv("TLG_JAPANESE_FALLBACK");
if (!enabled || strcmp(enabled,"1") || !guest) return 0;
const char *p=guest;
if (*p=='/') {
if (!strncmp(p,"/app0/",6)) p+=6;
else if (!strncmp(p,"/hostapp/",9)) p+=9;
else return 0;
}
for (;;) {
while (*p=='/') ++p;
if (p[0]=='.' && p[1]=='/') { p+=2; continue; }
break;
}
return language_archive_name(p,"trico_frf.psarc") ||
language_archive_name(p,"trico_frfp.psarc");
}
/* Resolve a guest path to a host path; returns 0 or a host errno. */
static int translate(const char *guest,char *out,size_t size) {
if (!guest || !*guest) return ENOENT;
@@ -107,6 +138,7 @@ static int translate(const char *guest,char *out,size_t size) {
else snprintf(buffer,sizeof(buffer),"%s",guest);
for (const char *p=buffer;(p=strstr(p,".."));p+=2)
if ((p==buffer || p[-1]=='/') && (p[2]==0 || p[2]=='/')) return EACCES;
if (japanese_hidden_archive(buffer)) return ENOENT;
pthread_mutex_lock(&lock);
size_t best=0; const Mount *m=NULL;
for (size_t i=0;i<mount_count;++i) {
@@ -136,13 +168,16 @@ static int host_flags(int flags) {
return r|O_CLOEXEC;
}
static void free_listing(Listing *l) { if (l) { free(l->names); free(l->offsets); free(l->types); free(l); } }
static Listing *list_directory(const char *path) {
static Listing *list_directory(const char *path,const char *guest) {
DIR *d=opendir(path);
if (!d) return NULL;
Listing *l=calloc(1,sizeof(*l));
size_t capacity=0,bytes=0,cap_names=0;
struct dirent *e;
while (l && (e=readdir(d))) {
char child_guest[1400];
if ((size_t)snprintf(child_guest,sizeof(child_guest),"%s/%s",guest,e->d_name)<sizeof(child_guest) &&
japanese_hidden_archive(child_guest)) continue;
size_t n=strlen(e->d_name)+1;
if (l->count==capacity) {
capacity=capacity ? capacity*2 : 64;
@@ -227,7 +262,7 @@ static int64_t do_open(const char *guest,int flags,int mode) {
int host=-1;
if (!host_stat(path,&s) && S_ISDIR(s.st_mode)) {
if (flags&3) return -EISDIR;
if (!(dir=list_directory(path))) return -EACCES;
if (!(dir=list_directory(path,guest))) return -EACCES;
} else if (flags&0x20000) {
e=errno==ENOENT ? ENOENT : ENOTDIR;
if (e==ENOENT) { ++missing; printf("Runtime: open(%s) -> not found\n",guest); }
@@ -252,7 +287,7 @@ static int64_t do_open(const char *guest,int flags,int mode) {
}
struct stat s;
Listing *dir=NULL;
if (!fstat(host,&s) && S_ISDIR(s.st_mode)) dir=list_directory(path);
if (!fstat(host,&s) && S_ISDIR(s.st_mode)) dir=list_directory(path,guest);
#endif
pthread_mutex_lock(&lock);
int fd=-1;
+85
View File
@@ -0,0 +1,85 @@
/* The Japanese-text view must look like missing French packs, without touching host files. */
#undef NDEBUG
#include "../src/runtime_file.c"
#include <assert.h>
static int32_t guest_errno;
int32_t *runtime_errno(void) { return &guest_errno; }
int32_t runtime_guest_errno(int e) { return e; }
void runtime_wait_begin(const char *kind,const void *object,const char *name,const void *const *owner,uintptr_t frame) {
(void)kind; (void)object; (void)name; (void)owner; (void)frame;
}
void runtime_wait_end(void) {}
uintptr_t runtime_lookup(const RuntimeExport *table,size_t count,const char *name) {
(void)table; (void)count; (void)name; return 0;
}
static void make_file(const char *path) {
FILE *f=fopen(path,"wb"); assert(f);
assert(fputs("unchanged archive",f)>=0 && !fclose(f));
}
static void readable(const char *guest) {
int fd=(int)do_open(guest,0,0); assert(fd>=3);
char data[32]={0}; assert(do_read(fd,data,17)==17);
assert(!strcmp(data,"unchanged archive") && !do_close(fd));
}
static int listed(const char *guest,const char *name) {
int fd=(int)do_open(guest,0x20000,0); assert(fd>=3);
char entries[4096]; int64_t count=do_getdents(fd,entries,sizeof(entries),NULL); assert(count>0);
int found=0;
for (int64_t at=0;at<count;) {
uint16_t length; memcpy(&length,entries+at+4,2); assert(length);
if (!strcmp(entries+at+8,name)) found=1;
at+=length;
}
assert(!do_close(fd)); return found;
}
int main(void) {
char root[512];
snprintf(root,sizeof(root),"%s/tlg-jp-files-XXXXXX",getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp");
assert(mkdtemp(root));
char game[600],user[600],path[800],nested[800];
snprintf(game,sizeof(game),"%s/game",root);
snprintf(user,sizeof(user),"%s/user",root);
assert(!mkdir(game,0755));
const char *packs[]={"trico_frf.psarc","trico_frfp.psarc","trico_boot.psarc","trico_frc.psarc"};
for (size_t i=0;i<4;++i) { snprintf(path,sizeof(path),"%s/%s",game,packs[i]); make_file(path); }
snprintf(nested,sizeof(nested),"%s/nested",game); assert(!mkdir(nested,0755));
snprintf(path,sizeof(path),"%s/nested/trico_frf.psarc",game); make_file(path);
unsetenv("TLG_JAPANESE_FALLBACK");
runtime_file_configure(game,user);
snprintf(path,sizeof(path),"%s/data/trico_frf.psarc",user); make_file(path);
readable("/app0/trico_frf.psarc"); readable("/app0/trico_frfp.psarc");
assert(listed("/app0","trico_frf.psarc") && listed("/hostapp","trico_frfp.psarc"));
setenv("TLG_JAPANESE_FALLBACK","1",1);
const char *hidden[]={"trico_frf.psarc","/app0/trico_frf.psarc","/hostapp/trico_frfp.psarc",
"./trico_frfp.psarc","/app0/./trico_frf.psarc","/app0//TRICO_FRF.PSARC"};
for (size_t i=0;i<sizeof(hidden)/sizeof(*hidden);++i) {
GuestStat info;
assert(do_open(hidden[i],0,0)==-ENOENT);
assert(do_stat(hidden[i],&info)==-ENOENT);
}
assert(!listed("/app0","trico_frf.psarc") && !listed("/hostapp","trico_frfp.psarc"));
assert(!listed(".","trico_frf.psarc") && !listed("/app0/.","trico_frfp.psarc"));
assert(listed("/app0","trico_boot.psarc") && listed("/app0","trico_frc.psarc"));
readable("/app0/trico_boot.psarc"); readable("/app0/trico_frc.psarc");
readable("/app0/nested/trico_frf.psarc"); readable("/data/trico_frf.psarc");
assert(listed("/app0/nested","trico_frf.psarc") && listed("/data","trico_frf.psarc"));
assert(do_open("/app0/nested/../trico_frf.psarc",0,0)==-EACCES);
for (int i=0;i<2;++i) {
setenv("TLG_JAPANESE_FALLBACK",i ? "invalid" : "0",1);
readable("/app0/trico_frf.psarc"); readable("/app0/trico_frfp.psarc");
assert(listed("/app0","trico_frf.psarc"));
}
unsetenv("TLG_JAPANESE_FALLBACK");
readable("/hostapp/trico_frf.psarc");
for (size_t i=0;i<4;++i) { snprintf(path,sizeof(path),"%s/%s",game,packs[i]); assert(!unlink(path)); }
snprintf(path,sizeof(path),"%s/nested/trico_frf.psarc",game); assert(!unlink(path));
assert(!rmdir(nested) && !rmdir(game));
snprintf(path,sizeof(path),"%s/data/trico_frf.psarc",user); assert(!unlink(path));
const char *dirs[]={"temp0","download0","data"};
for (size_t i=0;i<3;++i) { snprintf(path,sizeof(path),"%s/%s",user,dirs[i]); assert(!rmdir(path)); }
assert(!rmdir(user) && !rmdir(root));
puts("Japanese text view: open/stat/list masking, isolation and reversible host-file preservation PASS");
}
+112
View File
@@ -0,0 +1,112 @@
from paths import ROOT
import os
import struct
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(ROOT / 'launcher'))
import tlg_run
def param_sfo(title='CUSA03745', version='01.03'):
keys, values, entries = bytearray(), bytearray(), []
for key, value in [('TITLE_ID', title), ('APP_VER', version)]:
encoded = value.encode() + b'\0'
size = (len(encoded) + 3) & ~3
entries.append(struct.pack('<HHIII', len(keys), 0x204, len(encoded), size, len(values)))
keys += key.encode() + b'\0'
values += encoded + b'\0' * (size - len(encoded))
key_offset = 20 + 16 * len(entries)
value_offset = (key_offset + len(keys) + 3) & ~3
return (struct.pack('<4sIIII', b'\0PSF', 0x101, key_offset, value_offset, len(entries)) +
b''.join(entries) + keys + b'\0' * (value_offset - key_offset - len(keys)) + values)
class JapaneseLanguageTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.game = Path(self.tmp.name)
(self.game / 'sce_sys').mkdir()
for name in ('trico_boot.psarc', 'trico_bootp.psarc', 'trico_frf.psarc', 'trico_frfp.psarc'):
(self.game / name).write_bytes(b'untouched archive fixture')
self.metadata()
self.settings = {'game_dir': str(self.game), 'game_language': 'ja'}
def metadata(self, title='CUSA03745', version='01.03'):
(self.game / 'sce_sys/param.sfo').write_bytes(param_sfo(title, version))
def test_verified_layout_offers_explicit_japanese_choice(self):
self.assertTrue(tlg_run.japanese_fallback_available(self.game))
self.assertIn('ja', tlg_run.game_language_choices(dict(self.settings, game_language='auto')))
self.assertEqual(tlg_run.resolve_game_language(self.settings, {}), 'ja')
self.assertNotIn('ja', tlg_run.game_languages(self.game)) # native engine table unchanged
def test_japanese_emits_internal_french_id_and_guest_mask(self):
self.assertEqual(tlg_run.game_language_environment(self.settings, {}),
{'TLG_LANGUAGE': '2', 'TLG_JAPANESE_FALLBACK': '1'})
def test_ordinary_french_keeps_both_french_archives_visible(self):
s = dict(self.settings, game_language='fr')
self.assertEqual(tlg_run.game_language_environment(s, {'TLG_JAPANESE_FALLBACK': '1'}),
{'TLG_LANGUAGE': '2', 'TLG_JAPANESE_FALLBACK': '0'})
def test_other_languages_do_not_inherit_a_stale_mask(self):
for choice, code in [('en', '1'), ('de', '4')]:
self.assertEqual(tlg_run.game_language_environment(dict(self.settings, game_language=choice),
{'TLG_JAPANESE_FALLBACK': '1'}),
{'TLG_LANGUAGE': code, 'TLG_JAPANESE_FALLBACK': '0'})
def test_automatic_does_not_silently_opt_into_experimental_fallback(self):
self.assertEqual(tlg_run.game_language_environment(dict(self.settings, game_language='auto'),
{'LANG': 'ja_JP.UTF-8'}),
{'TLG_LANGUAGE': '1', 'TLG_JAPANESE_FALLBACK': '0'})
def test_other_regions_and_versions_are_not_advertised(self):
for title, version in [('CUSA03627', '01.03'), ('CUSA04936', '01.03'),
('CUSA04943', '01.03'), ('CUSA99999', '01.03'),
('CUSA03745', '01.00'), ('CUSA03745', '01.04')]:
self.metadata(title, version)
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
self.assertNotIn('ja', tlg_run.game_language_choices(dict(self.settings, game_language='auto')))
self.assertEqual(tlg_run.game_language_environment(self.settings, {}),
{'TLG_LANGUAGE': '1', 'TLG_JAPANESE_FALLBACK': '0'})
def test_missing_boot_archive_or_bad_metadata_fails_closed(self):
(self.game / 'trico_bootp.psarc').unlink()
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
(self.game / 'sce_sys/param.sfo').write_bytes(b'broken')
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
with patch.object(tlg_run.shader_cache, 'title_id', return_value='CUSA03745'), \
patch.object(tlg_run.mod_patches, 'game_app_version', side_effect=struct.error('broken')):
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
def test_explicit_engine_override_is_not_combined_with_wrong_mask(self):
self.assertEqual(tlg_run.game_language_environment(self.settings, {'TLG_LANGUAGE': '1'}),
{'TLG_LANGUAGE': '1', 'TLG_JAPANESE_FALLBACK': '0'})
def test_preexisting_manual_workaround_does_not_need_french_archives_restored(self):
for name in ('trico_frf.psarc', 'trico_frfp.psarc'):
(self.game / name).unlink()
self.assertTrue(tlg_run.japanese_fallback_available(self.game))
self.assertEqual(tlg_run.game_language_environment(self.settings, {})['TLG_JAPANESE_FALLBACK'], '1')
def test_build_env_uses_the_same_language_policy(self):
with patch.dict(os.environ, {}, clear=True), \
patch.object(tlg_run, 'window_icon', return_value=None), \
patch.object(tlg_run, 'ngs2_module', return_value=None), \
patch.object(tlg_run, 'user_dir', return_value=self.game / 'unused-user'), \
patch.object(tlg_run, 'probe_env', return_value={}):
settings = dict(tlg_run.DEFAULT_SETTINGS, **self.settings)
env = tlg_run.build_env(settings)
self.assertEqual(env['TLG_LANGUAGE'], '2')
self.assertEqual(env['TLG_JAPANESE_FALLBACK'], '1')
def test_environment_planning_does_not_modify_game_or_saves(self):
before = {str(p.relative_to(self.game)): p.read_bytes() for p in self.game.rglob('*') if p.is_file()}
tlg_run.game_language_environment(self.settings, {})
after = {str(p.relative_to(self.game)): p.read_bytes() for p in self.game.rglob('*') if p.is_file()}
self.assertEqual(before, after)