From ec44854e43b55fa5cd7839b1b7a16776df0dc382 Mon Sep 17 00:00:00 2001 From: Zeldris <80939130+zeldrisho@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:11:14 +0700 Subject: [PATCH 1/5] feat: add configurable Unity Vietnamese font sync plugin --- .editorconfig | 19 ++ .github/workflows/ci.yml | 41 +++ .github/workflows/release.yml | 48 +++ .gitignore | 10 + AGENTS.md | 37 ++ CHANGELOG.md | 16 + LICENSE | 21 ++ NuGet.Config | 8 + README.md | 106 ++++++ UnityVietnameseFontSync.slnx | 8 + docs/configuration.md | 94 ++++++ docs/development.md | 114 +++++++ docs/plan.md | 22 ++ global.json | 7 + scripts/package.py | 91 +++++ scripts/test_package.py | 55 +++ src/FontRules.cs | 82 +++++ src/PluginSettings.cs | 72 ++++ src/TmpRuntime.cs | 110 ++++++ src/UnityVietnameseFontSync.csproj | 21 ++ src/UnityVietnameseFontSyncPlugin.cs | 242 ++++++++++++++ src/packages.lock.json | 371 +++++++++++++++++++++ tests/ConfigurationTests.cs | 99 ++++++ tests/TmpRuntimeTests.cs | 106 ++++++ tests/UnityVietnameseFontSync.Tests.csproj | 16 + tests/packages.lock.json | 144 ++++++++ 26 files changed, 1960 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 NuGet.Config create mode 100644 README.md create mode 100644 UnityVietnameseFontSync.slnx create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 docs/plan.md create mode 100644 global.json create mode 100644 scripts/package.py create mode 100644 scripts/test_package.py create mode 100644 src/FontRules.cs create mode 100644 src/PluginSettings.cs create mode 100644 src/TmpRuntime.cs create mode 100644 src/UnityVietnameseFontSync.csproj create mode 100644 src/UnityVietnameseFontSyncPlugin.cs create mode 100644 src/packages.lock.json create mode 100644 tests/ConfigurationTests.cs create mode 100644 tests/TmpRuntimeTests.cs create mode 100644 tests/UnityVietnameseFontSync.Tests.csproj create mode 100644 tests/packages.lock.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..200f1b4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,csproj,props,config,slnx}] +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +[**/obj/**] +generated_code = true +end_of_line = unset diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c17af13 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: "10.0.402" + cache: true + cache-dependency-path: "**/packages.lock.json" + + - run: dotnet restore UnityVietnameseFontSync.slnx --locked-mode + - run: dotnet format whitespace UnityVietnameseFontSync.slnx --no-restore --verify-no-changes + - run: dotnet build UnityVietnameseFontSync.slnx -c Release --no-restore --warnaserror + - run: dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release --no-build --no-restore + - run: python3 -m unittest discover -s scripts -p 'test_*.py' + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: plugin + path: src/bin/Release/netstandard2.1/UnityVietnameseFontSync.dll + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1604052 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + check: + uses: ./.github/workflows/ci.yml + + publish: + needs: check + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: plugin + path: artifacts/plugin + + - name: Validate release metadata and package plugin + env: + RELEASE_TAG: ${{ github.ref_name }} + run: python3 scripts/package.py --tag "$RELEASE_TAG" --dll artifacts/plugin/UnityVietnameseFontSync.dll + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: >- + gh release create "$RELEASE_TAG" + artifacts/release/*.zip + --verify-tag --title "$RELEASE_TAG" + --notes-file artifacts/release/notes.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9dfac39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +**/bin/ +**/obj/ +.vs/ +.idea/ +*.user +*.suo +Directory.Build.props +artifacts/ +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28c50a1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Agent Instructions + +## Toolchain + +- Use stable .NET 10 via `global.json` and Python 3 for packaging checks. +- Restore with `dotnet restore UnityVietnameseFontSync.slnx --locked-mode`. +- Stop and report missing development tools or dependencies. + +## Commands + +| Task | Command | +|---|---| +| Configuration/adapter tests | `dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release` | +| Release packaging tests | `python3 -m unittest discover -s scripts -p 'test_*.py'` | +| Check formatting | `dotnet format whitespace UnityVietnameseFontSync.slnx --no-restore --verify-no-changes` | +| Build | `dotnet build UnityVietnameseFontSync.slnx -c Release --no-restore --warnaserror` | + +## Key Conventions + +- Edit plugin identity/version in `src/UnityVietnameseFontSync.csproj`; do not edit generated `MyPluginInfo` constants. +- Keep game paths/mappings, game DLLs, and proprietary fonts out of committed runtime/build configuration. +- Keep runtime compatibility checks capability-based; do not add game-name/version heuristics. +- Keep destructive synchronization opt-in and unmatched fonts unchanged. +- Update lock files with dependency changes; review Unity baseline upgrades for compatibility. +- Record user-visible changes under `Unreleased` in `CHANGELOG.md`. +- Do not deploy, publish, or commit unless requested. + +## External References + +| Need | File | +|---|---| +| User setup | `README.md` | +| Configuration contract | `docs/configuration.md` | +| Runtime design, build, testing, release | `docs/development.md` | +| Change history | `CHANGELOG.md` | +| CI | `.github/workflows/ci.yml` | +| Published releases | `.github/workflows/release.yml` | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..43dc4c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable user-facing changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), +and this project uses [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +- Vietnamese glyph fallback for supported Unity Mono games using TextMeshPro. +- Optional font synchronization to keep accented and unaccented letters visually consistent. +- Multiple font profiles, ordered matching rules, and exclusions for fonts such as icons and symbols. +- Configurable font files, atlas settings, and support for late-loaded game fonts. +- Warnings for missing font files, unsupported TMP APIs, and characters the selected font cannot provide. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..89ae11e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zeldris + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..1280bf4 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8b562f --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Unity Vietnamese Font Sync + +A configurable **BepInEx 5 plugin for Unity Mono games using TextMeshPro**. Add missing Vietnamese glyphs or use a consistent replacement font for selected font groups. + +This plugin does not translate text. It can work alongside XUnity.AutoTranslator or an existing Vietnamese translation. + +## Status + +**Development build; in-game validation is pending.** Automated tests and API-surface checks against two different game assembly sets pass. They do not verify native font generation or rendering. + +- Initial runtime target: Windows, BepInEx 5, Unity Mono, compatible TextMeshPro. +- No game-specific font mappings or paths are built in. +- BepInEx 6, IL2CPP, legacy Unity UI, NGUI, and UI Toolkit are not supported. +- TMP APIs are discovered at runtime; missing required capabilities disable font changes with a log message. This is not a guarantee of compatibility with every Unity/TMP version. + +## Features + +- Named font profiles and explicitly ordered matching rules. +- **Fallback:** retain original glyphs and append a Vietnamese-capable font. +- **Synchronize:** opt-in replacement of a matched font group's glyphs. +- Preserve existing fallback chains; leave unmatched fonts unchanged. +- Validate required glyph coverage before applying a profile. +- Discover late-loaded fonts with configurable scanning, retry delays, and diagnostics. +- Load local font files without downloading fonts or using a translation service. + +## Installation + +1. Install [BepInEx 5 for Unity Mono](https://docs.bepinex.dev/articles/user_guide/installation/index.html). +2. Extract the release archive into `/BepInEx/`, or [build from source](docs/development.md) and place the DLL at: + ```text + /BepInEx/plugins/zeldrisho.UnityVietnameseFontSync/UnityVietnameseFontSync.dll + ``` +3. Run the game once with the plugin installed to generate: + ```text + /BepInEx/config/zeldrisho.UnityVietnameseFontSync.cfg + ``` +4. Close the game, configure a font file and matching rules, then start it again. + +There is no need for a separate BepInEx-only run before installing this plugin. If you previously used a UnityExplorer font script, restart with that script disabled and do not run it alongside the plugin. + +The initial configuration makes **no font changes**. No game assemblies or proprietary font files are bundled. + +## Quick configuration + +Enable `Diagnostics = true` to log TMP font asset names, or optionally inspect them with UnityExplorer. Match font asset names, not component names. + +Example for a fictional `BodyText SDF` asset: + +```ini +[Fonts] +Profiles = body + +[Font.body] +File = MyVietnameseFont.ttf + +[Rules] +Order = body-text +Exclude = Icons, Symbols + +[Rule.body-text] +Profile = body +Match = Exact +Pattern = BodyText SDF +Mode = Fallback +``` + +Place the font in the plugin's `Fonts/` directory, select a filename from Windows Fonts, or specify an absolute path. Use a font you are licensed to use that covers all required characters. + +See [configuration](docs/configuration.md) for multiple profiles, exclusions, synchronization, and atlas settings. + +**Synchronization clears the matched font's character table in memory.** Use narrow rules and check symbols, untranslated text, and layout. Restart to recover. Matching outlines, shadows, spacing, and line breaks is not guaranteed. + +## With XUnity.AutoTranslator + +When this plugin owns TMP fonts, start with XUnity's competing font settings empty: + +```ini +[Behaviour] +OverrideFontTextMeshPro= +FallbackFontTextMeshPro= +``` + +Keep translation enabled. This plugin neither edits XUnity configuration nor rewrites translated strings. Avoid `RemoveAllDiacritics` in translation postprocessing. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Nothing changes | `Rules.Order`, exact asset names, `Enabled`, and profile `File`; rules are empty by default | +| Profile disabled | Log warnings for missing files, API failures, or missing `U+...` characters; try another font or larger atlas | +| Accents look wrong | Fallback cannot replace an existing incorrect glyph; consider narrowly targeted synchronization | +| Mixed styles | Fallback preserves available original glyphs; synchronization changes the whole matched font group | +| Icons/other languages disappear | Avoid broad synchronization and verify preserved fallback coverage | +| Combining marks fail | Add marks to `CustomCharacters`; test the source font and TMP shaping behavior | + +To disable, set `Enabled = false` and restart. To uninstall, close the game and remove the plugin DLL. + +## Development + +See [development](docs/development.md) for build/test commands, runtime design, and release steps. Notable changes are recorded in [CHANGELOG.md](CHANGELOG.md). + +CI checks formatting, locked restore, compilation, tests, and packaging logic. A matching version tag publishes the release ZIP after those checks pass. + +## License + +[MIT](LICENSE). The license covers this plugin and its documentation, not third-party fonts or game assemblies. Do not redistribute proprietary system fonts or game assets. diff --git a/UnityVietnameseFontSync.slnx b/UnityVietnameseFontSync.slnx new file mode 100644 index 0000000..84acce6 --- /dev/null +++ b/UnityVietnameseFontSync.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..d2b59f9 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,94 @@ +# Configuration + +File: `BepInEx/config/zeldrisho.UnityVietnameseFontSync.cfg`. Edit while the game is closed and restart after changes. Hot reload is not supported. + +## Global settings + +| Section | Key | Default | Behavior | +|---|---|---|---| +| General | Enabled | true | Enable plugin | +| General | Diagnostics | false | Log each live TMP font asset name once | +| General | ScanInterval | 2 | Discovery interval in seconds, 0–300; zero disables periodic scans, not startup/scene scans | +| General | RetryInterval | 30 | Delay before retrying a failed font mutation, 1–600 seconds | +| Fonts | Profiles | default | Comma-separated profile IDs | +| Fonts | CustomCharacters | empty | Characters added to the ASCII/Vietnamese preset for every profile | +| Rules | Order | empty | Comma-separated rule IDs in first-match order | +| Rules | Exclude | empty | Case-insensitive asset-name fragments excluded before all rules | + +IDs contain letters, digits, underscores, or hyphens. Lists trim whitespace and remove exact duplicates. IDs/profile references are case-sensitive; asset matching is case-insensitive. Unlisted sections are inactive. + +The preset covers printable ASCII and uppercase/lowercase precomposed Vietnamese. Add curly quotes, ellipsis, combining marks, or other required characters explicitly. Game text is not normalized or rewritten. Invalid surrogate sequences are rejected. + +## Profiles: `[Font.]` + +| Key | Default | Allowed values | +|---|---|---| +| File | empty | Font filename, relative path, or absolute path; empty disables profile | +| SamplingSize | 36 | 8–128 | +| Padding | 4 | 1–32 | +| AtlasSize | 2048 | 256, 512, 1024, 2048, 4096 | +| MultiAtlas | true | Allow additional dynamic atlas pages | + +Relative paths search `/Fonts/` first, then the OS Fonts directory. Absolute paths are used directly. Paths must be valid on the machine running the game; a Linux `/mnt/c/...` path is not a Windows runtime path. + +Profiles must successfully add the entire preset plus custom characters. Missing glyphs or atlas population failures disable the profile and log missing Unicode values where TMP supplies them. Mapped game fonts are left alone. Files are loaded once at startup; restart to retry after fixing a file. + +## Rules: `[Rule.]` + +| Key | Default | Behavior | +|---|---|---| +| Profile | default | An ID in `Fonts.Profiles` | +| Match | Exact | `Exact`, `Contains`, or `All` | +| Pattern | empty | Required for Exact/Contains; ignored for All | +| Mode | Fallback | `Fallback` or `Synchronize` | + +- First match wins. An unavailable target profile does not fall through to another rule. +- `All` explicitly matches every non-excluded game font. `*` is not a wildcard in Exact/Contains. +- Fallback appends the profile after existing fallbacks; original glyphs remain unchanged. +- Synchronize inserts the profile first, freezes the original atlas, clears its whole character table, and rebuilds lookup data. Existing fallbacks remain afterward. +- Synchronize can break symbols and non-Latin text absent from the replacement and preserved fallbacks. There is no automatic icon detection or serif/sans classification. + +## Multiple-profile example + +```ini +[Fonts] +Profiles = body, menu +CustomCharacters = …“” + +[Font.body] +File = BodyFont.ttf + +[Font.menu] +File = MenuFont.ttf +SamplingSize = 40 + +[Rules] +Order = body, menu +Exclude = Icons, Symbols + +[Rule.body] +Profile = body +Match = Contains +Pattern = BodyText +Mode = Fallback + +[Rule.menu] +Profile = menu +Match = Exact +Pattern = MenuText SDF +Mode = Synchronize +``` + +Names are illustrative, not built-in game mappings. Start with exact Fallback rules; inspect coverage and appearance before enabling Synchronize. + +## Replacing a manual UnityExplorer script + +Manual C# font changes affect the current process; they are not plugin configuration. Disable any saved/startup font script, close the game completely, and start with this plugin to generate its configuration. Recreate the intended font-to-asset mappings as profiles and rules; there are no existing plugin settings to migrate. + +For example, the previously tested two-style setup maps `MortyOld` asset-name fragments to `palab.ttf` (Palatino Linotype Bold) and `Feruka` fragments to `bahnschrift.ttf` (Bahnschrift), using Synchronize for both. Those are optional user mappings, not built-in defaults or recommendations for every game. Use separate rules for each group instead of an `else` rule replacing all remaining fonts. Verify the files exist and pass glyph preflight on the target machine. + +Use Fallback when only missing-character coverage is needed. Use Synchronize when a matched font group should render both accented and ordinary letters through the selected profile, including games whose existing fallback already provides Vietnamese but looks visually inconsistent. + +## Recovery + +Invalid IDs, atlas sizes, unknown profile references, and empty matching patterns disable initialization. BepInEx may use defaults for malformed typed values; check logs and generated comments. No disk game assets are modified. Restarting without the plugin or manual font scripts returns to the game's original loaded assets. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..b7fe67d --- /dev/null +++ b/docs/development.md @@ -0,0 +1,114 @@ +# Development + +## Toolchain and layout + +- Stable .NET 10 SDK, Git, and Python 3 for release packaging/tests. +- `global.json` accepts installed stable .NET 10 SDK feature bands; CI pins SDK 10.0.402, the latest stable .NET 10 SDK reported when configured. Local checks used 10.0.112. +- `src/`: the plugin project; `tests/`: Unity-independent tests; `scripts/`: release packaging. +- Public compile-time packages and lock files make clean builds independent of game installations. Never commit or distribute game assemblies or proprietary fonts. + +[Microsoft's .NET documentation](https://learn.microsoft.com/en-us/dotnet/) covers the [CLI](https://learn.microsoft.com/en-us/dotnet/core/tools/), [formatting](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format), and [GitHub Actions](https://learn.microsoft.com/en-us/dotnet/devops/github-actions-overview). + +## Commands + +Run from the repository root: + +```bash +dotnet restore UnityVietnameseFontSync.slnx --locked-mode +dotnet format whitespace UnityVietnameseFontSync.slnx --no-restore --verify-no-changes +dotnet build UnityVietnameseFontSync.slnx -c Release --no-restore --warnaserror +dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release --no-build --no-restore +python3 -m unittest discover -s scripts -p 'test_*.py' +``` + +To apply formatting, omit `--verify-no-changes`. Output: `src/bin/Release/netstandard2.1/UnityVietnameseFontSync.dll`. Deploy only that DLL, manually, to the game's plugin directory. Build/test commands never deploy. + +The project originated from official [BepInEx.Templates](https://github.com/BepInEx/BepInEx.Templates). Stable 1.4.0 generation failed under SDK 10.0.112; documented template 2.0.0-be.4 worked. That scaffolding exception does not require a bleeding-edge runtime. Restore/build does not require templates to remain installed. + +Dependencies use exact versions plus lock files. BepInEx packages use the latest stable versions available in the configured feed when checked. UnityEngine.Modules 2022.3.0 is an intentional compile-only compatibility baseline, not a required game version; blindly selecting the newest Unity package could introduce newer runtime API dependencies. No Unity/TMP/game DLL ships with the plugin. `Version`, display name, and GUID live in `src/UnityVietnameseFontSync.csproj`; template tooling generates constants for the plugin attribute. The initial stable version is `1.0.0`, matching the .NET SDK default and using three-part SemVer. The SDK derives the assembly/file versions (`1.0.0.0` initially); no separate version constants are maintained. + +## Runtime design + +| File | Responsibility | +|---|---| +| `src/FontRules.cs` | Matching, list parsing, paths, Unicode preset | +| `src/PluginSettings.cs` | One BepInEx config; profiles, rules, validation | +| `src/TmpRuntime.cs` | Discover and validate TMP capabilities without a TMP compile-time reference | +| `src/UnityVietnameseFontSyncPlugin.cs` | Generation, discovery, mutation, rollback, refresh | + +At startup the adapter probes loaded TMP types, required properties, character APIs, and a compatible multi-argument Font-to-SDF creation overload. Unsupported capabilities prevent mutation; scene/periodic scans retry discovery so late-loaded TMP assemblies can be handled, with warnings logged only once. Optional trailing creation parameters use their declared defaults. Version strings are not used to choose behavior. Native runtime failures can still occur and need in-game testing. + +Profiles are preflighted independently. Startup, scene changes, and periodic scans select the first matching rule after exclusions. Live object references track successful patches; destroyed entries are pruned. Per-font failures attempt snapshot rollback and use the configured retry delay. Generated profiles have no links back to game fonts, avoiding newly introduced fallback cycles. + +Snapshots preserve original fallbacks/atlas mode and, for Synchronize, characters. Only text whose primary font directly changed is refreshed; text using that asset indirectly through fallbacks may need normal game refresh. Strings are never temporarily cleared. Original glyph tables and atlas textures are not cleared. + +Generated assets/pages are pinned. Best-effort restoration runs at plugin destruction, but used generated materials remain until process exit because cached submeshes/other plugins may reference them. Hot unloading and live configuration reload are not supported. Restoration may conflict with another plugin modifying the same font; restart is the recovery path. + +## Validation + +### Manual baseline and limits + +The working starting point was C# run inside UnityExplorer, not an installed version of this plugin or an existing plugin configuration: + +- In Bō (Unity 2022.3.0f1, Mono), user-reported menu/settings success used Palatino Linotype Bold (`palab.ttf`) for `MortyOld` and Bahnschrift (`bahnschrift.ttf`) for `Feruka`, with sampling size 36, padding 4, 2048-square dynamic replacement atlases, and ASCII/Vietnamese preloading. +- The successful synchronization approach froze matched original assets to Static, cleared their character tables, rebuilt lookup data, and used replacement fallbacks. The plugin implements that approach through explicit rules while preserving original fallback chains. +- Missing glyphs and visually incorrect existing glyphs are different problems. Removing only lookup entries did not resolve the manual failure; a dynamic original font could add glyphs again. A fallback alone also mixed accented/unaccented styles. +- A tested Trebuchet replacement produced missing-character boxes. That observation is not a universal coverage claim about every font release; preflight each actual file. Never assume an atlas size guarantees sufficient capacity. +- Feather's Edge (Unity 6000.0.49f1, Mono) already logged successful XUnity fallback creation; style consistency remains a separate use case. No complete replacement mapping or new-plugin rendering result has been verified there. +- TMP package versions cannot be established from those logs alone. Original metrics/material behavior, all font names, gameplay screens beyond the reported menus, long-term XUnity interactions, and performance remain unverified. Do not carry forward speculative explanations or claims of perfect shader preservation. + +A texture PNG alone is not a portable TMP font asset: glyph mappings, metrics, and other font data are also required. Runtime-generated fonts are not exported by this plugin. Restarting clears manual in-memory changes; disable startup scripts before testing the plugin independently. + +### Automated and in-game checks + +Unit tests exercise configuration, matching, paths, Unicode scalars, adapter capability checks, optional parameters, and failed glyph-preflight reporting. Packaging tests check version/tag validation, changelog requirements, the exact four-file layout, documentation links, and deterministic archive contents. These are not native rendering tests. + +Optional local API-surface check (no game process or native font calls): + +```bash +GAME_MANAGED_PATH="/path/to/Game_Data/Managed" \ + dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release --filter ExternalGameApi +``` + +The optional check loads the selected assembly set in an isolated context and is explicitly skipped without the environment variable. It passed against both available Unity 2022-era and Unity 6-era game reference sets. No game names or paths are encoded in tests or runtime decisions. + +Before claiming runtime support, manually test: + +- Exact Fallback and Synchronize rules, multiple profiles, missing files/glyphs, and atlas exhaustion. +- All Vietnamese cases/tones, combining marks, punctuation, icons, original-language fallbacks, rich text, and weights. +- Outlines/shadows, spacing, wrapping, clipping, and small/large sizes. +- Startup, scenes, async loads, inactive text activation, retries, and long-session atlas growth. +- XUnity coexistence with its competing TMP font settings empty. +- Discovery/refresh timings and memory in multiple games. No rendering/performance claims are verified yet. + +## CI and release + +Workflows use minimal permissions, timeouts, concurrency controls, and action SHA pins with stable version comments. Review dependency updates manually and retain lock files. Review the Unity compatibility baseline rather than blindly accepting engine upgrades. + +CI restores in locked mode, checks formatting, builds, runs tests, checks packaging logic, and uploads only the plugin DLL. It does not need Steam or private reference assemblies. + +To release: + +1. Finish manual validation and review the MIT license and any third-party redistribution rights. +2. Set the project `Version`; move relevant user-facing `Unreleased` changes to `## [X.Y.Z] - YYYY-MM-DD` in `CHANGELOG.md` and retain `Unreleased` above it. Keep developer instructions, CI changes, and test implementation details out of the changelog. +3. Validate packaging locally, commit, and push a matching stable `vX.Y.Z` tag only when ready to publish. +4. The release workflow reruns CI, validates tag/version/changelog consistency, creates the allowlisted ZIP, and publishes a normal GitHub release automatically. **Tagging is the publication trigger; there is no draft/manual-publish step.** + +Local packaging after adding the dated release entry: + +```bash +python3 scripts/package.py --tag vX.Y.Z --dll src/bin/Release/netstandard2.1/UnityVietnameseFontSync.dll +``` + +The initial archive is `UnityVietnameseFontSync-1.0.0.zip`; future archives retain the `UnityVietnameseFontSync-X.Y.Z.zip` pattern. It contains exactly: + +```text +plugins/zeldrisho.UnityVietnameseFontSync/UnityVietnameseFontSync.dll +plugins/zeldrisho.UnityVietnameseFontSync/README.md +plugins/zeldrisho.UnityVietnameseFontSync/CHANGELOG.md +plugins/zeldrisho.UnityVietnameseFontSync/LICENSE +``` + +Extract into `/BepInEx/`, not the game root. The install directory is derived from the project GUID; the DLL name remains the assembly name. Guides stay in the repository. During GitHub packaging, README guide links point to that repository's release tag; local packages without GitHub metadata use plain-text documentation references instead of broken local links. + +The ZIP is the only attached release asset. The generated `notes.md` becomes the GitHub release body, not an archive entry. Separate checksum files are optional and omitted for this small distribution. No fonts, game/BepInEx/Unity DLLs, generated config, debug symbols, or internal planning notes are included. Users supply fonts and generate per-game configuration on first launch. Comparison links can be added once the repository remote and first release exist. diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..0a8a18b --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,22 @@ +# Remaining work + +## Runtime validation + +- [ ] Run the new runtime-adapter plugin in multiple Unity Mono games; record actual BepInEx, Unity, and TMP versions. +- [ ] Verify startup and late TMP assembly loading, missing/stripped capabilities, and native font creation failures. +- [ ] Validate Fallback/Synchronize independently, failed profile safety, symbols, combining marks, original-language fallbacks, rich text, and weights. +- [ ] Compare outlines, shadows, spacing, wrapping, and clipping at different sizes. +- [ ] Test scenes, late assets, inactive text activation, retries, and long-session multi-atlas memory behavior. +- [ ] Verify indirect fallback users refresh; extend refresh propagation if required. +- [ ] Verify XUnity coexistence and measure discovery/refresh costs. +- [ ] Add Unity integration coverage for mutation rollback and cleanup; API-surface and fake-adapter tests do not verify native rendering. + +## Release readiness + +- [ ] Confirm redistribution rights for any future third-party resources; plugin code and documentation now use MIT. +- [ ] Run the workflows on GitHub after a repository remote is configured; local checks cannot verify hosted artifact/release permissions. +- [ ] Validate the release archive and runtime behavior before pushing a version tag; the workflow publishes a normal release automatically. +- [ ] Add changelog tag/comparison links once the remote and first release exist. +- [ ] Recheck stable BepInEx.Templates generation with newer SDKs; the existing project does not need template regeneration. + +The two permanent guides are [configuration.md](configuration.md) and [development.md](development.md). Architecture notes are consolidated into development rather than maintained as a third guide. diff --git a/global.json b/global.json new file mode 100644 index 0000000..d46d21e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/scripts/package.py b/scripts/package.py new file mode 100644 index 0000000..fed0db6 --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Validate a stable release and package only plugin-owned files.""" + +import argparse +import datetime +import os +from pathlib import Path +import re +import xml.etree.ElementTree as ET +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +PROJECT = "UnityVietnameseFontSync" + + +def release_notes(changelog, version): + match = re.search( + rf"^## \[{re.escape(version)}\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*\n(.*?)(?=^## |\Z)", + changelog, + re.MULTILINE | re.DOTALL, + ) + if not match: + raise ValueError(f"Add a dated [{version}] entry to CHANGELOG.md before tagging.") + datetime.date.fromisoformat(match.group(1)) + notes = match.group(2).strip() + if not notes or not re.search(r"^- \S", notes, re.MULTILINE): + raise ValueError("Release notes must contain a human-readable change entry.") + return notes + "\n" + + +def package(root, tag, dll, output, repository_url=None): + if not re.fullmatch(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", tag): + raise ValueError("Use a stable SemVer tag such as v1.0.0.") + version = tag[1:] + project = ET.parse(root / "src" / f"{PROJECT}.csproj") + if project.findtext(".//Version") != version: + raise ValueError("Release tag must match the project Version.") + notes = release_notes((root / "CHANGELOG.md").read_text(encoding="utf-8"), version) + plugin_id = project.findtext(".//BepInExPluginGuid") + if not plugin_id or not re.fullmatch(r"[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*", plugin_id): + raise ValueError("Project BepInExPluginGuid must be a safe directory name.") + prefix = f"plugins/{plugin_id}" + # An explicit allowlist prevents game DLLs, fonts, local notes, or build dependencies leaking. + files = { + f"{prefix}/{PROJECT}.dll": dll, + f"{prefix}/README.md": root / "README.md", + f"{prefix}/CHANGELOG.md": root / "CHANGELOG.md", + f"{prefix}/LICENSE": root / "LICENSE", + } + for source in files.values(): + if not source.is_file(): + raise ValueError(f"Missing package input: {source}") + output.mkdir(parents=True, exist_ok=True) + archive = output / f"{PROJECT}-{version}.zip" + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle: + for name, source in files.items(): + entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + entry.compress_type = zipfile.ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + content = source.read_bytes() + if source == root / "README.md": + # Guides stay in the repository, not the install archive. Avoid broken local links. + def documentation_link(match): + label, path = match.groups() + if repository_url: + return f"[{label}]({repository_url.rstrip('/')}/blob/{tag}/{path})" + return f"{label} (see the project repository)" + content = re.sub(r"\[([^\]]+)\]\((docs/[^)]+)\)", documentation_link, + content.decode("utf-8")).encode("utf-8") + bundle.writestr(entry, content) + (output / "notes.md").write_text(notes, encoding="utf-8") + return archive + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--dll", type=Path, required=True) + parser.add_argument("--output", type=Path, default=ROOT / "artifacts/release") + args = parser.parse_args() + try: + repository = os.environ.get("GITHUB_REPOSITORY") + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + repository_url = f"{server}/{repository}" if repository else None + print(package(ROOT, args.tag, args.dll, args.output, repository_url)) + except ValueError as error: + parser.exit(1, f"{error}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_package.py b/scripts/test_package.py new file mode 100644 index 0000000..9fb222b --- /dev/null +++ b/scripts/test_package.py @@ -0,0 +1,55 @@ +from pathlib import Path +import tempfile +import unittest +import zipfile + +from package import package, release_notes + + +class PackageTests(unittest.TestCase): + def test_notes_require_dated_nonempty_release(self): + self.assertEqual("### Added\n- Example.\n", release_notes( + "## [1.0.0] - 2026-10-15\n\n### Added\n- Example.\n\n## [0.9.0] - 2026-09-01\n", "1.0.0")) + for text in ("## [Unreleased]\n- Example.", "## [1.0.0] - 2026-99-99\n- Example.", + "## [1.0.0] - 2026-10-15\n"): + with self.assertRaises(ValueError): + release_notes(text, "1.0.0") + + def test_exact_layout_version_and_reproducibility(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "docs").mkdir() + (root / "src/UnityVietnameseFontSync.csproj").write_text( + "1.0.0" + "zeldrisho.UnityVietnameseFontSync" + "") + (root / "CHANGELOG.md").write_text("## [1.0.0] - 2026-10-15\n### Added\n- Example.\n") + for name in ("README.md", "LICENSE", "docs/configuration.md", "docs/development.md"): + (root / name).write_text("example") + (root / "README.md").write_text("See [configuration](docs/configuration.md).") + (root / "private.ttf").write_text("must not ship") + dll = root / "UnityVietnameseFontSync.dll" + dll.write_bytes(b"test fixture") + first = package(root, "v1.0.0", dll, root / "out1") + second = package(root, "v1.0.0", dll, root / "out2") + self.assertEqual(first.name, "UnityVietnameseFontSync-1.0.0.zip") + self.assertEqual(first.read_bytes(), second.read_bytes()) + prefix = "plugins/zeldrisho.UnityVietnameseFontSync/" + with zipfile.ZipFile(first) as bundle: + self.assertEqual(set(bundle.namelist()), { + prefix + name for name in ("UnityVietnameseFontSync.dll", "README.md", "CHANGELOG.md", "LICENSE") + }) + self.assertNotIn(b"docs/configuration.md", bundle.read(prefix + "README.md")) + self.assertEqual({file.name for file in first.parent.iterdir()}, {first.name, "notes.md"}) + hosted = package(root, "v1.0.0", dll, root / "hosted", "https://github.com/example/project") + with zipfile.ZipFile(hosted) as bundle: + self.assertIn(b"https://github.com/example/project/blob/v1.0.0/docs/configuration.md", + bundle.read(prefix + "README.md")) + for tag in ("v1.0.1", "v1.0.0-beta", "../../evil", "v01.0.0"): + with self.assertRaises(ValueError): + package(root, tag, dll, root / "bad") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/FontRules.cs b/src/FontRules.cs new file mode 100644 index 0000000..423daae --- /dev/null +++ b/src/FontRules.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace UnityVietnameseFontSync +{ + public enum MatchKind { Exact, Contains, All } + public enum PatchMode { Fallback, Synchronize } + + public sealed class FontRule + { + public string Id; + public string Profile; + public string Pattern; + public MatchKind Match; + public PatchMode Mode; + public bool Matches(string name) + { + if (Match == MatchKind.All) return true; + if (string.IsNullOrEmpty(Pattern)) return false; + return Match == MatchKind.Exact + ? string.Equals(name, Pattern, StringComparison.OrdinalIgnoreCase) + : name.IndexOf(Pattern, StringComparison.OrdinalIgnoreCase) >= 0; + } + } + + public static class FontRules + { + public static string[] Split(string value) + { + var result = new List(); + foreach (string part in (value ?? "").Split(',')) + if (part.Trim().Length > 0 && !result.Contains(part.Trim())) result.Add(part.Trim()); + return result.ToArray(); + } + + public static FontRule Select(string name, IEnumerable rules, string[] exclusions) + { + foreach (string exclusion in exclusions) + if (name.IndexOf(exclusion, StringComparison.OrdinalIgnoreCase) >= 0) return null; + foreach (FontRule rule in rules) if (rule.Matches(name)) return rule; + return null; + } + + public static string ResolvePath(string file, string fontsDirectory, string customDirectory) + { + if (string.IsNullOrWhiteSpace(file)) return null; + if (Path.IsPathRooted(file)) return file; + string custom = Path.Combine(customDirectory, file); + if (File.Exists(custom)) return custom; + return Path.Combine(fontsDirectory, file); + } + + public static uint[] Characters(string extra) + { + var text = new StringBuilder(); + for (int i = 32; i <= 126; i++) text.Append((char)i); + const string vowels = "aăâeêioôơuưyAĂÂEÊIOÔƠUƯY"; + const string tones = "\u0300\u0301\u0303\u0309\u0323"; + text.Append("đĐ").Append(vowels); + foreach (char vowel in vowels) + foreach (char tone in tones) + text.Append((vowel.ToString() + tone).Normalize(NormalizationForm.FormC)); + // Combining marks are optional custom characters: source fonts may not support them. + text.Append(extra ?? ""); + var result = new HashSet(); + string value = text.ToString(); + for (int i = 0; i < value.Length; i++) + { + if (char.IsHighSurrogate(value[i]) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + result.Add((uint)char.ConvertToUtf32(value[i], value[++i])); + else if (!char.IsSurrogate(value[i])) result.Add(value[i]); + else throw new ArgumentException("Custom characters contain an unpaired surrogate."); + } + var codes = new uint[result.Count]; + result.CopyTo(codes); + Array.Sort(codes); + return codes; + } + } +} diff --git a/src/PluginSettings.cs b/src/PluginSettings.cs new file mode 100644 index 0000000..f3f5378 --- /dev/null +++ b/src/PluginSettings.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using BepInEx.Configuration; + +namespace UnityVietnameseFontSync +{ + internal sealed class FontProfile + { + public string Id, File; + public int Size, Padding, Atlas; + public bool MultiAtlas; + } + + internal sealed class PluginSettings + { + public readonly List Profiles = new List(); + public readonly List Rules = new List(); + public string[] Exclusions; + public uint[] Characters; + public float ScanInterval, RetryInterval; + public bool Enabled, Diagnostics; + + public PluginSettings(ConfigFile config) + { + Enabled = config.Bind("General", "Enabled", true, "Enable plugin. Restart after configuration changes.").Value; + Diagnostics = config.Bind("General", "Diagnostics", false, "Log discovered font names once per live asset.").Value; + ScanInterval = config.Bind("General", "ScanInterval", 2f, new ConfigDescription("Seconds between discovery scans; 0 disables periodic scans.", new AcceptableValueRange(0f, 300f))).Value; + RetryInterval = config.Bind("General", "RetryInterval", 30f, new ConfigDescription("Seconds before retrying a failed font mutation.", new AcceptableValueRange(1f, 600f))).Value; + Exclusions = FontRules.Split(config.Bind("Rules", "Exclude", "", "Comma-separated asset-name fragments excluded before all rules; include your game's icon fonts here.").Value); + Characters = FontRules.Characters(config.Bind("Fonts", "CustomCharacters", "", "Extra characters in addition to ASCII and precomposed Vietnamese. No translated text is normalized.").Value); + foreach (string id in FontRules.Split(config.Bind("Fonts", "Profiles", "default", "Comma-separated profile IDs.").Value)) + { + ValidateId(id); + string section = "Font." + id; + Profiles.Add(new FontProfile + { + Id = id, + File = config.Bind(section, "File", "", "Font filename or absolute path. Empty disables this profile. Relative paths search plugin Fonts then Windows Fonts.").Value, + Size = config.Bind(section, "SamplingSize", 36, new ConfigDescription("Font sampling size.", new AcceptableValueRange(8, 128))).Value, + Padding = config.Bind(section, "Padding", 4, new ConfigDescription("SDF padding.", new AcceptableValueRange(1, 32))).Value, + Atlas = config.Bind(section, "AtlasSize", 2048, "Square atlas size: 256, 512, 1024, 2048, or 4096.").Value, + MultiAtlas = config.Bind(section, "MultiAtlas", true, "Allow additional atlas textures.").Value + }); + int atlas = Profiles[Profiles.Count - 1].Atlas; + if (atlas < 256 || atlas > 4096 || (atlas & (atlas - 1)) != 0) throw new ArgumentException(section + ": invalid AtlasSize."); + } + foreach (string id in FontRules.Split(config.Bind("Rules", "Order", "", "Comma-separated rule IDs in first-match order. Empty means no mutation.").Value)) + { + ValidateId(id); + string section = "Rule." + id; + var rule = new FontRule + { + Id = id, + Profile = config.Bind(section, "Profile", "default", "Profile ID to use.").Value, + Pattern = config.Bind(section, "Pattern", "", "Case-insensitive font asset name or fragment.").Value, + Match = config.Bind(section, "Match", MatchKind.Exact, "Exact, Contains, or All. All explicitly matches every non-excluded asset.").Value, + Mode = config.Bind(section, "Mode", PatchMode.Fallback, "Fallback preserves glyphs; Synchronize clears all original characters and freezes the original atlas. Opt-in only.").Value + }; + if (!Enum.IsDefined(typeof(MatchKind), rule.Match) || !Enum.IsDefined(typeof(PatchMode), rule.Mode)) throw new ArgumentException(section + ": invalid mode."); + if (!Profiles.Exists(p => p.Id == rule.Profile)) throw new ArgumentException(section + ": unknown profile " + rule.Profile); + if (rule.Match != MatchKind.All && string.IsNullOrWhiteSpace(rule.Pattern)) throw new ArgumentException(section + ": Pattern must not be empty."); + Rules.Add(rule); + } + } + + private static void ValidateId(string id) + { + foreach (char c in id) + if (!char.IsLetterOrDigit(c) && c != '_' && c != '-') throw new ArgumentException("Profile/rule IDs must contain only letters, digits, underscores or hyphens: " + id); + } + } +} diff --git a/src/TmpRuntime.cs b/src/TmpRuntime.cs new file mode 100644 index 0000000..eb97a84 --- /dev/null +++ b/src/TmpRuntime.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; + +namespace UnityVietnameseFontSync +{ + // This adapter deliberately has no compile-time dependency on a game's TMP assembly. + internal sealed class TmpRuntime + { + public Type FontType { get; } + public Type TextType { get; } + private readonly MethodInfo _create, _addCharacters, _readDefinition, _refresh, _dirty; + private readonly PropertyInfo _characters, _fallbacks, _mode, _textures, _textFont; + private readonly MemberInfo _material; + + public TmpRuntime(Type fontType, Type textType, Type unityFontType) + { + FontType = fontType; + TextType = textType; + _characters = RequireProperty(fontType, "characterTable"); + _fallbacks = RequireProperty(fontType, "fallbackFontAssetTable", true); + _mode = RequireProperty(fontType, "atlasPopulationMode", true); + _textures = RequireProperty(fontType, "atlasTextures"); + _textFont = RequireProperty(textType, "font"); + _material = (MemberInfo)fontType.GetProperty("material") ?? fontType.GetField("material"); + if (_material == null) throw new NotSupportedException("TMP font material is unavailable."); + _create = fontType.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(method => + { + var p = method.GetParameters(); + return method.Name == "CreateFontAsset" && p.Length >= 8 && + p[0].ParameterType == unityFontType && p[1].ParameterType == typeof(int) && + p[2].ParameterType == typeof(int) && p[3].ParameterType.IsEnum && + p[4].ParameterType == typeof(int) && p[5].ParameterType == typeof(int) && + p[6].ParameterType == _mode.PropertyType && p[7].ParameterType == typeof(bool) && + p.Skip(8).All(parameter => parameter.HasDefaultValue); + }); + if (_create == null) throw new NotSupportedException("TMP lacks a compatible runtime Font-to-SDF creation overload."); + _addCharacters = RequireMethod(fontType, "TryAddCharacters", typeof(uint[]), typeof(uint[]).MakeByRefType(), typeof(bool)); + _readDefinition = RequireMethod(fontType, "ReadFontAssetDefinition"); + _refresh = RequireMethod(textType, "ForceMeshUpdate", typeof(bool), typeof(bool)); + _dirty = RequireMethod(textType, "SetAllDirty"); + // Validate enum names at startup, before any game assets can be modified. + Enum.Parse(_mode.PropertyType, "Static"); + Enum.Parse(_mode.PropertyType, "Dynamic"); + Enum.Parse(_create.GetParameters()[3].ParameterType, "SDFAA"); + } + + public static TmpRuntime Discover(Type unityFontType) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type font = assembly.GetType("TMPro.TMP_FontAsset", false); + Type text = assembly.GetType("TMPro.TMP_Text", false); + if (font != null && text != null) return new TmpRuntime(font, text, unityFontType); + } + throw new NotSupportedException("No loaded TMP_FontAsset/TMP_Text API found. Legacy UI and IL2CPP are not supported."); + } + + public object Create(object source, int size, int padding, int atlas, bool multiAtlas) + { + var parameters = _create.GetParameters(); + var arguments = new object[parameters.Length]; + object[] required = { source, size, padding, Enum.Parse(parameters[3].ParameterType, "SDFAA"), + atlas, atlas, Enum.Parse(_mode.PropertyType, "Dynamic"), multiAtlas }; + Array.Copy(required, arguments, required.Length); + for (int i = required.Length; i < arguments.Length; i++) arguments[i] = parameters[i].DefaultValue; + return _create.Invoke(null, arguments); + } + + public bool AddCharacters(object font, uint[] codes, out uint[] missing) + { + object[] arguments = { codes, null, false }; + bool complete = (bool)_addCharacters.Invoke(font, arguments); + missing = (uint[])arguments[1]; + return complete; + } + + public IList Characters(object font) => (IList)_characters.GetValue(font, null); + public IList Fallbacks(object font) => (IList)_fallbacks.GetValue(font, null); + public void SetFallbacks(object font, IList value) => _fallbacks.SetValue(font, value, null); + public IList NewFallbacks() => (IList)Activator.CreateInstance(_fallbacks.PropertyType); + public object Mode(object font) => _mode.GetValue(font, null); + public void SetMode(object font, object mode) => _mode.SetValue(font, mode, null); + public void Freeze(object font) => SetMode(font, Enum.Parse(_mode.PropertyType, "Static")); + public IEnumerable Textures(object font) => (IEnumerable)_textures.GetValue(font, null); + public object Material(object font) => _material is PropertyInfo property + ? property.GetValue(font, null) : ((FieldInfo)_material).GetValue(font); + public object TextFont(object text) => _textFont.GetValue(text, null); + public void ReadDefinition(object font) => _readDefinition.Invoke(font, null); + public void Refresh(object text) + { + _dirty.Invoke(text, null); + _refresh.Invoke(text, new object[] { true, true }); + } + + private static PropertyInfo RequireProperty(Type type, string name, bool writable = false) + { + PropertyInfo property = type.GetProperty(name); + if (property == null || !property.CanRead || (writable && !property.CanWrite)) + throw new NotSupportedException(type.FullName + "." + name + " is unavailable."); + return property; + } + + private static MethodInfo RequireMethod(Type type, string name, params Type[] parameters) + { + return type.GetMethod(name, parameters) ?? throw new NotSupportedException(type.FullName + "." + name + " has no compatible overload."); + } + } +} diff --git a/src/UnityVietnameseFontSync.csproj b/src/UnityVietnameseFontSync.csproj new file mode 100644 index 0000000..1fdb869 --- /dev/null +++ b/src/UnityVietnameseFontSync.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.1 + UnityVietnameseFontSync + UnityVietnameseFontSync + Unity Vietnamese Font Sync + MIT + zeldrisho.UnityVietnameseFontSync + 1.0.0 + latest + true + + + + + + + + + diff --git a/src/UnityVietnameseFontSyncPlugin.cs b/src/UnityVietnameseFontSyncPlugin.cs new file mode 100644 index 0000000..4eb1caa --- /dev/null +++ b/src/UnityVietnameseFontSyncPlugin.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using BepInEx; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +namespace UnityVietnameseFontSync +{ + [BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)] + public sealed class UnityVietnameseFontSyncPlugin : BaseUnityPlugin + { + private PluginSettings _settings; + private TmpRuntime _tmp; + private readonly Dictionary _profiles = new Dictionary(); + private readonly List _owned = new List(); + private readonly Dictionary _patched = new Dictionary(); + private readonly HashSet _reported = new HashSet(); + private readonly Dictionary _retry = new Dictionary(); + private float _nextScan; + private bool _ready, _assetsInstalled, _apiWarningLogged; + + private void Awake() + { + try { _settings = new PluginSettings(Config); } + catch (Exception ex) { Logger.LogError("Invalid configuration; no fonts will be changed: " + ex); } + } + + private void Start() + { + if (_settings == null || !_settings.Enabled) return; + _ready = true; + SceneManager.sceneLoaded += OnSceneLoaded; + Scan(); + } + + private void Update() + { + if (!_ready || _settings.ScanInterval <= 0 || Time.unscaledTime < _nextScan) return; + Scan(); + } + + private void OnSceneLoaded(Scene scene, LoadSceneMode mode) { Scan(); } + + private void Build(FontProfile profile) + { + string directory = Path.Combine(Path.GetDirectoryName(Info.Location), "Fonts"); + string path = FontRules.ResolvePath(profile.File, Environment.GetFolderPath(Environment.SpecialFolder.Fonts), directory); + if (path == null) return; + if (!File.Exists(path)) throw new FileNotFoundException("Font file not found", path); + int start = _owned.Count; + try + { + Font source = new Font(path); + Own(source); + Object asset = (Object)_tmp.Create(source, profile.Size, profile.Padding, profile.Atlas, profile.MultiAtlas); + if (asset == null) throw new InvalidOperationException("TMP font creation returned null."); + Own(asset); + asset.name = MyPluginInfo.PLUGIN_GUID + "." + profile.Id; + Own(_tmp.Material(asset) as Object); + bool complete; + uint[] missing; + try { complete = _tmp.AddCharacters(asset, _settings.Characters, out missing); } + finally { OwnTextures(asset); } + if (!complete) + { + var codes = new List(); + foreach (uint code in missing ?? new uint[0]) codes.Add("U+" + code.ToString("X4")); + throw new InvalidOperationException("Required glyphs could not be added: " + string.Join(", ", codes.ToArray())); + } + _profiles.Add(profile.Id, asset); + Logger.LogInfo("Loaded profile '" + profile.Id + "' with " + _settings.Characters.Length + " required characters."); + } + catch + { + for (int i = _owned.Count - 1; i >= start; i--) { Destroy(_owned[i]); _owned.RemoveAt(i); } + throw; + } + } + + private void OwnTextures(Object font) + { + IEnumerable textures = _tmp.Textures(font); + if (textures != null) foreach (object texture in textures) Own(texture as Object); + } + + private void Own(Object value) + { + if (value == null || _owned.Contains(value)) return; + value.hideFlags |= HideFlags.DontUnloadUnusedAsset; + _owned.Add(value); + } + + private void Scan() + { + if (!_ready) return; + _nextScan = Time.unscaledTime + _settings.ScanInterval; + if (_tmp == null) + { + // TMP may be loaded after this plugin starts. Retry discovery, without log spam. + try { _tmp = TmpRuntime.Discover(typeof(Font)); } + catch (Exception ex) + { + if (!_apiWarningLogged) Logger.LogWarning("TMP API unavailable; no fonts changed. Discovery will retry: " + ex); + _apiWarningLogged = true; + return; + } + foreach (FontProfile profile in _settings.Profiles) + { + try { Build(profile); } + catch (Exception ex) { Logger.LogWarning("Profile '" + profile.Id + "' disabled: " + ex); } + } + } + foreach (Object font in new List(_patched.Keys)) if (font == null) _patched.Remove(font); + _reported.RemoveWhere(font => font == null); + foreach (Object font in new List(_retry.Keys)) if (font == null) _retry.Remove(font); + // Pin atlas pages added by TMP since preflight, not just the initial pages. + foreach (Object profile in _profiles.Values) if (profile != null) OwnTextures(profile); + if (_profiles.Count == 0 && !_settings.Diagnostics) return; + var changed = new HashSet(); + foreach (Object font in Resources.FindObjectsOfTypeAll(_tmp.FontType)) + { + if (font == null || _owned.Contains(font) || _patched.ContainsKey(font)) continue; + if (_settings.Diagnostics && _reported.Add(font)) Logger.LogInfo("Discovered TMP font: " + font.name); + if (_retry.TryGetValue(font, out float next) && Time.unscaledTime < next) continue; + FontRule rule = FontRules.Select(font.name, _settings.Rules, _settings.Exclusions); + if (rule == null || !_profiles.TryGetValue(rule.Profile, out Object target)) continue; + Snapshot snapshot = null; + try + { + snapshot = new Snapshot(_tmp, font, rule.Mode == PatchMode.Synchronize); + bool modified = false; + IList fallbacks = _tmp.Fallbacks(font); + if (fallbacks == null) { fallbacks = _tmp.NewFallbacks(); _tmp.SetFallbacks(font, fallbacks); } + // Generated profiles have no edges back to game fonts. + if (!fallbacks.Contains(target)) + { + _assetsInstalled = true; + if (rule.Mode == PatchMode.Synchronize) fallbacks.Insert(0, target); + else fallbacks.Add(target); + modified = true; + } + if (rule.Mode == PatchMode.Synchronize) + { + if (fallbacks.IndexOf(target) > 0) + { + fallbacks.Remove(target); + fallbacks.Insert(0, target); + } + _tmp.Freeze(font); + _tmp.Characters(font).Clear(); + _tmp.ReadDefinition(font); + modified = true; + } + _patched.Add(font, snapshot); + _retry.Remove(font); + if (modified) changed.Add(font); + Logger.LogInfo("Applied '" + rule.Id + "' to '" + font.name + "' (" + rule.Mode + ")."); + } + catch (Exception ex) + { + try { if (snapshot != null) snapshot.Restore(_tmp, font); } + catch (Exception restore) + { + _patched[font] = snapshot; + Logger.LogError("Rollback failed; retaining snapshot and generated assets: " + restore); + } + _retry[font] = Time.unscaledTime + _settings.RetryInterval; + Logger.LogWarning("Font patch failed; retry delayed: " + ex); + } + } + if (changed.Count == 0) return; + foreach (Object value in Resources.FindObjectsOfTypeAll(_tmp.TextType)) + { + var text = value as Component; + if (text == null || !text.gameObject.scene.IsValid()) continue; + try + { + if (changed.Contains(_tmp.TextFont(text) as Object)) _tmp.Refresh(text); + } + catch (Exception ex) { Logger.LogWarning("Text refresh failed: " + ex.Message); } + } + } + + private void OnDestroy() + { + SceneManager.sceneLoaded -= OnSceneLoaded; + bool restored = true; + foreach (var pair in _patched) + { + if (pair.Key == null) continue; + try { pair.Value.Restore(_tmp, pair.Key); } + catch (Exception ex) { restored = false; Logger.LogError("Restoration failed; retaining generated assets: " + ex); } + } + // Used materials can remain referenced by cached submeshes. Restart to reclaim them. + if (restored && !_assetsInstalled) + { + foreach (Object asset in _profiles.Values) if (asset != null) OwnTextures(asset); + for (int i = _owned.Count - 1; i >= 0; i--) if (_owned[i] != null) Destroy(_owned[i]); + } + } + + private sealed class Snapshot + { + private readonly object _mode; + private readonly object[] _characters, _fallbacks; + public Snapshot(TmpRuntime tmp, Object font, bool saveCharacters) + { + _mode = tmp.Mode(font); + _characters = saveCharacters ? Copy(tmp.Characters(font)) : null; + _fallbacks = Copy(tmp.Fallbacks(font)); + } + public void Restore(TmpRuntime tmp, Object font) + { + tmp.SetMode(font, _mode); + if (_characters != null) + { + IList characters = tmp.Characters(font); + characters.Clear(); + foreach (object character in _characters) characters.Add(character); + } + IList fallbacks = null; + if (_fallbacks != null) + { + fallbacks = tmp.NewFallbacks(); + foreach (object fallback in _fallbacks) fallbacks.Add(fallback); + } + tmp.SetFallbacks(font, fallbacks); + tmp.ReadDefinition(font); + } + private static object[] Copy(IList values) + { + if (values == null) return null; + var copy = new object[values.Count]; + values.CopyTo(copy, 0); + return copy; + } + } + } +} diff --git a/src/packages.lock.json b/src/packages.lock.json new file mode 100644 index 0000000..ad42ec2 --- /dev/null +++ b/src/packages.lock.json @@ -0,0 +1,371 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "BepInEx.Analyzers": { + "type": "Direct", + "requested": "[1.0.8, )", + "resolved": "1.0.8", + "contentHash": "xrfNmunsPhBx+vStTxLonq/aHkRrDH77c9tG/x3m5eejrKe5B0nf7cJPRRt6x330sGI0bLaPTtygdeHUgvI3wQ==" + }, + "BepInEx.Core": { + "type": "Direct", + "requested": "[5.4.21, )", + "resolved": "5.4.21", + "contentHash": "NMUPlbHTTfJ+qIQCI90uIvjuUQ4wnwt4cpRsK3ItBh1DhsWFzAHXNiZjBxZkPysljEKQ2iu89sxMTga4bxBXVQ==", + "dependencies": { + "BepInEx.BaseLib": "5.4.20", + "HarmonyX": "2.7.0" + } + }, + "BepInEx.PluginInfoProps": { + "type": "Direct", + "requested": "[2.1.0, )", + "resolved": "2.1.0", + "contentHash": "VCG3QRiqNdW9ku2FEcNsbK+HKxrZmW0VxwMNyLNO7h0xGorU+C6vBHN8Qq4eAL5fU11Uks5x2uoYdqEoKD3P8A==" + }, + "UnityEngine.Modules": { + "type": "Direct", + "requested": "[2022.3.0, )", + "resolved": "2022.3.0", + "contentHash": "IVY9KSiyE6JTJTnqI8jbb9MJePpptZbhKvrh/7OGb4IJ3tlmhdc1YjCxbxt/HwfTHbBprk0BMF0+hvz9tM0XXg==" + }, + "BepInEx.BaseLib": { + "type": "Transitive", + "resolved": "5.4.20", + "contentHash": "0bXgYxbCEN2Ixp3kiFEhyw+RASeFQeg/ww+lbMt7if6XMeVS60eg6epNsMA8Jbx57dmNOzNevkKKw8mP8SUMqw==" + }, + "HarmonyX": { + "type": "Transitive", + "resolved": "2.7.0", + "contentHash": "lec/SkduQspMa3Pi/nM/NSvA84Za8HCCWA8ybdToKiTqnBZa+JC5g6rxoFQCg/1vNuYcvjy77edePZzIEsRmvw==", + "dependencies": { + "MonoMod.RuntimeDetour": "21.12.13.1", + "System.Reflection.Emit": "4.7.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + }, + "Mono.Cecil": { + "type": "Transitive", + "resolved": "0.11.4", + "contentHash": "IC1h5g0NeJGHIUgzM1P82ld57knhP0IcQfrYITDPXlNpMYGUrsG5TxuaWTjaeqDNQMBDNZkB8L0rBnwsY6JHuQ==" + }, + "MonoMod.RuntimeDetour": { + "type": "Transitive", + "resolved": "21.12.13.1", + "contentHash": "65mB+bHjT6UCGCgO+/pYhpuGPf96rQ1Whwkut/x7cqVIW0DTndDFyWc/3bngzhnY/Y6IYtBMlXsU2ATq+CxpHg==", + "dependencies": { + "Mono.Cecil": "0.11.4", + "MonoMod.Utils": "21.12.13.1", + "System.Collections.NonGeneric": "4.3.0", + "System.ComponentModel.TypeConverter": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.7.0", + "System.Reflection.Emit.Lightweight": "4.7.0", + "System.Reflection.TypeExtensions": "4.7.0" + } + }, + "MonoMod.Utils": { + "type": "Transitive", + "resolved": "21.12.13.1", + "contentHash": "/H+0RMWqx/D1/fSuY5jhY6GFvqhdYdlnI7J3IfL8P6y4nJkoZDxqts6+RxWWOz4pbnJdWnxSjS8XG+Lq6rUi7w==", + "dependencies": { + "Mono.Cecil": "0.11.4", + "System.Collections.NonGeneric": "4.3.0", + "System.ComponentModel.TypeConverter": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.7.0", + "System.Reflection.Emit.Lightweight": "4.7.0", + "System.Reflection.TypeExtensions": "4.7.0" + } + }, + "System.Collections": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Collections.NonGeneric": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "prtjIEMhGUnQq6RnPEYLpFt8AtLbp9yq2zxOSrY7KJJZrw25Fi97IzBqY7iqssbM61Ek5b8f3MG/sG1N2sN5KA==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Collections.Specialized": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "Epx8PoVZR0iuOnJJDzp7pWvdfMMOAvpUo95pC4ScH2mJuXkKA2Y4aR3cG9qt2klHgSons1WFh4kcGW7cSXvrxg==", + "dependencies": { + "System.Collections.NonGeneric": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Extensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.ComponentModel": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VyGn1jGRZVfxnh8EdvDCi71v3bMXrsu8aYJOwoV7SNDLVhiEqwP86pPMyRGsDsxhXAm2b3o9OIqeETfN5qfezw==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.ComponentModel.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "j8GUkCpM8V4d4vhLIIoBLGey2Z5bCkMVNjEZseyAlm4n5arcsJOeI3zkUP+zvZgzsbLTYh4lYeP/ZD/gdIAPrw==", + "dependencies": { + "System.ComponentModel": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.ComponentModel.TypeConverter": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "16pQ6P+EdhcXzPiEK4kbA953Fu0MNG2ovxTZU81/qsCd1zPRsKc3uif5NgvllCY598k6bI0KUyKW8fanlfaDQg==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Collections.NonGeneric": "4.3.0", + "System.Collections.Specialized": "4.3.0", + "System.ComponentModel": "4.3.0", + "System.ComponentModel.Primitives": "4.3.0", + "System.Globalization": "4.3.0", + "System.Linq": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Diagnostics.Debug": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.FileSystem.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Linq": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Reflection": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "VR4kk8XLKebQ4MZuKuIni/7oh+QGFmZW3qORd1GvBq/8026OpW501SzT/oypwiQl4TvT8ErnReh/NzY9u+C6wQ==" + }, + "System.Reflection.Emit.ILGeneration": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "AucBYo3DSI0IDxdUjKksBcQJXPHyoPyrCXYURW1WDsLI4M65Ar/goSHjdnHOAY9MiYDNKqDlIgaYm+zL2hA1KA==" + }, + "System.Reflection.Emit.Lightweight": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "a4OLB4IITxAXJeV74MDx49Oq2+PsF6Sml54XAFv+2RyWwtDBcabzoxiiJRhdhx+gaohLh4hEGCLQyBozXoQPqA==" + }, + "System.Reflection.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "VybpaOQQhqE6siHppMktjfGBw1GCwvCqiufqmP8F1nj7fTUNtW35LOEt3UZTEsECfo+ELAl/9o9nJx3U91i7vA==" + }, + "System.Resources.ResourceManager": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Runtime.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.Handles": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.InteropServices": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Text.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Threading": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "dependencies": { + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Tasks": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + } + } + } +} \ No newline at end of file diff --git a/tests/ConfigurationTests.cs b/tests/ConfigurationTests.cs new file mode 100644 index 0000000..9480cb3 --- /dev/null +++ b/tests/ConfigurationTests.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using System.Linq; +using BepInEx.Configuration; +using UnityVietnameseFontSync; +using Xunit; + +public sealed class ConfigurationTests +{ + [Fact] + public void RulesUseFirstMatchAndExclusionsWin() + { + var exact = new FontRule { Pattern = "Body", Match = MatchKind.Exact }; + var all = new FontRule { Match = MatchKind.All }; + Assert.Same(exact, FontRules.Select("bOdY", new[] { exact, all }, new string[0])); + Assert.Same(all, FontRules.Select("Body Large", new[] { exact, all }, new string[0])); + Assert.Null(FontRules.Select("Icon Body", new[] { all }, new[] { "icon" })); + Assert.Null(FontRules.Select("Body", new FontRule[0], new string[0])); + } + + [Fact] + public void ContainsIsCaseInsensitiveAndEmptyNeverMatches() + { + Assert.True(new FontRule { Match = MatchKind.Contains, Pattern = "body" }.Matches("BODY SDF")); + Assert.False(new FontRule { Match = MatchKind.Contains, Pattern = "" }.Matches("BODY")); + } + + [Fact] + public void CharacterPresetIncludesVietnameseUpperLowerAndScalars() + { + uint[] codes = FontRules.Characters("😀\u0301"); + foreach (char c in "ăâêôơưđĂÂÊÔƠƯĐàáảãạằắẳẵặầấẩẫậèéẻẽẹềếểễệìíỉĩịòóỏõọồốổỗộờớởỡợùúủũụừứửữựỳýỷỹỵ") + { + Assert.Contains((uint)c, codes); + Assert.Contains((uint)char.ToUpperInvariant(c), codes); + } + Assert.Contains(0x1F600u, codes); + Assert.Contains(0x0301u, codes); + Assert.Equal(codes.Length, codes.Distinct().Count()); + Assert.Throws(() => FontRules.Characters("\uD800")); + } + + [Fact] + public void RelativePathsPreferCustomFiles() + { + string directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(directory); + try + { + string file = Path.Combine(directory, "font.ttf"); + File.WriteAllText(file, "test"); + Assert.Equal(file, FontRules.ResolvePath("font.ttf", "/os-fonts", directory)); + Assert.Equal(Path.Combine("/os-fonts", "other.ttf"), FontRules.ResolvePath("other.ttf", "/os-fonts", directory)); + Assert.Equal(file, FontRules.ResolvePath(file, "/os-fonts", directory)); + Assert.Null(FontRules.ResolvePath("", "/os-fonts", directory)); + } + finally { Directory.Delete(directory, true); } + } + + [Fact] + public void ListParsingTrimsAndDeduplicates() + { + Assert.Equal(new[] { "body", "menu" }, FontRules.Split(" body, ,menu,body ")); + } + + [Fact] + public void DefaultsNeverMutateFonts() + { + var settings = Load(""); + Assert.Empty(settings.Rules); + Assert.Equal("", settings.Profiles[0].File); + } + + [Theory] + [InlineData("[Fonts]\nProfiles = invalid.id\n")] + [InlineData("[Fonts]\nProfiles = default\n[Font.default]\nAtlasSize = 513\n")] + [InlineData("[Rules]\nOrder = body\n[Rule.body]\nProfile = missing\nPattern = Body\n")] + [InlineData("[Rules]\nOrder = body\n[Rule.body]\nPattern = \n")] + public void InvalidConfigurationIsRejected(string text) + { + Assert.Throws(() => Load(text)); + } + + [Fact] + public void ConfigurationPreservesExplicitOrder() + { + var settings = Load("[Rules]\nOrder = specific, remaining\n[Rule.specific]\nPattern = Body\n[Rule.remaining]\nMatch = All\nMode = Synchronize\n"); + Assert.Equal(new[] { "specific", "remaining" }, settings.Rules.Select(rule => rule.Id)); + Assert.Equal(PatchMode.Synchronize, settings.Rules[1].Mode); + } + + private static PluginSettings Load(string text) + { + string file = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".cfg"); + File.WriteAllText(file, text); + try { return new PluginSettings(new ConfigFile(file, false) { SaveOnConfigSet = false }); } + finally { File.Delete(file); } + } +} diff --git a/tests/TmpRuntimeTests.cs b/tests/TmpRuntimeTests.cs new file mode 100644 index 0000000..d50cc45 --- /dev/null +++ b/tests/TmpRuntimeTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.Loader; +using UnityVietnameseFontSync; +using Xunit; + +public sealed class TmpRuntimeTests +{ + [Fact] + public void AdapterValidatesAndInvokesCompatibleApi() + { + var api = new TmpRuntime(typeof(TestFont), typeof(TestText), typeof(TestSource)); + var font = (TestFont)api.Create(new TestSource(), 36, 4, 2048, true); + Assert.Equal(TestPopulation.Dynamic, api.Mode(font)); + Assert.True(api.AddCharacters(font, new uint[] { 65 }, out uint[] missing)); + Assert.Empty(missing); + Assert.Single(api.Characters(font)); + api.Freeze(font); + Assert.Equal(TestPopulation.Static, api.Mode(font)); + var fallback = api.NewFallbacks(); + fallback.Add(new TestFont()); + api.SetFallbacks(font, fallback); + Assert.Single(api.Fallbacks(font)); + var text = new TestText { font = font }; + Assert.Same(font, api.TextFont(text)); + api.Refresh(text); + Assert.True(text.Refreshed); + } + + [Fact] + public void AdapterReportsFailedGlyphPreflight() + { + var api = new TmpRuntime(typeof(TestFont), typeof(TestText), typeof(TestSource)); + Assert.False(api.AddCharacters(new TestFont(), new uint[] { 0xFFFF }, out uint[] missing)); + Assert.Equal(new uint[] { 0xFFFF }, missing); + } + + [Fact] + public void MissingCapabilitiesAreRejectedBeforeMutation() + { + Assert.Throws(() => new TmpRuntime(typeof(object), typeof(TestText), typeof(TestSource))); + Assert.Throws(() => new TmpRuntime(typeof(TestFont), typeof(object), typeof(TestSource))); + } + + [GameApiFact] + public void ExternalGameApiHasRequiredCapabilities() + { + string directory = Path.GetFullPath(Environment.GetEnvironmentVariable("GAME_MANAGED_PATH")); + var context = new AssemblyLoadContext("game-api-check", isCollectible: true); + context.Resolving += (loader, name) => + { + string path = Path.Combine(directory, name.Name + ".dll"); + return File.Exists(path) && name.Name.StartsWith("Unity", StringComparison.Ordinal) + ? loader.LoadFromAssemblyPath(path) : null; + }; + try + { + var tmp = context.LoadFromAssemblyPath(Path.Combine(directory, "Unity.TextMeshPro.dll")); + var rendering = context.LoadFromAssemblyPath(Path.Combine(directory, "UnityEngine.TextRenderingModule.dll")); + var api = new TmpRuntime(tmp.GetType("TMPro.TMP_FontAsset", true), tmp.GetType("TMPro.TMP_Text", true), + rendering.GetType("UnityEngine.Font", true)); + Assert.NotNull(api.FontType); + } + finally { context.Unload(); } + } + + public enum TestPopulation { Static, Dynamic } + public enum TestRender { SDFAA } + public sealed class TestSource { } + public sealed class TestFont + { + public List characterTable { get; } = new List(); + public List fallbackFontAssetTable { get; set; } + public TestPopulation atlasPopulationMode { get; set; } + public object[] atlasTextures { get; } = new object[0]; + public object material { get; } = new object(); + public static TestFont CreateFontAsset(TestSource source, int size, int padding, TestRender render, + int width, int height, TestPopulation mode, bool multiAtlas, bool optional = false) + => new TestFont { atlasPopulationMode = mode }; + public bool TryAddCharacters(uint[] characters, out uint[] missing, bool includeFeatures) + { + if (Array.IndexOf(characters, 0xFFFFu) >= 0) { missing = new uint[] { 0xFFFF }; return false; } + characterTable.AddRange(characters); + missing = new uint[0]; + return true; + } + public void ReadFontAssetDefinition() { } + } + public sealed class TestText + { + public TestFont font { get; set; } + public bool Refreshed; + public void SetAllDirty() { } + public void ForceMeshUpdate(bool ignoreActive, bool reparse) { Refreshed = true; } + } + + public sealed class GameApiFactAttribute : FactAttribute + { + public GameApiFactAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GAME_MANAGED_PATH"))) + Skip = "Set GAME_MANAGED_PATH for a local API-surface check; this is not a rendering test."; + } + } +} diff --git a/tests/UnityVietnameseFontSync.Tests.csproj b/tests/UnityVietnameseFontSync.Tests.csproj new file mode 100644 index 0000000..758ddef --- /dev/null +++ b/tests/UnityVietnameseFontSync.Tests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + false + true + + + + + + + + + + + diff --git a/tests/packages.lock.json b/tests/packages.lock.json new file mode 100644 index 0000000..47683c4 --- /dev/null +++ b/tests/packages.lock.json @@ -0,0 +1,144 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "BepInEx.Core": { + "type": "Direct", + "requested": "[5.4.21, )", + "resolved": "5.4.21", + "contentHash": "NMUPlbHTTfJ+qIQCI90uIvjuUQ4wnwt4cpRsK3ItBh1DhsWFzAHXNiZjBxZkPysljEKQ2iu89sxMTga4bxBXVQ==", + "dependencies": { + "BepInEx.BaseLib": "5.4.20", + "HarmonyX": "2.7.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.11.0, )", + "resolved": "18.11.0", + "contentHash": "xf/bR0LNkhK0SnadDRlZIQmC4sac5Wk0e1WAdiQjRN3MSo6gQ9M2c4c1XRNnVGjfOvckh704W5CJ19AL0/Xsdg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.11.0", + "Microsoft.TestPlatform.TestHost": "18.11.0" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "8CoAaZkniVekR1ycJyxn+hOz+6ZQCDPcoIsIs6ujd4WyFcBMlxGNU15Qb8Uxd4iO4UmOuETb0M6vzBbwX/mfLA==" + }, + "BepInEx.BaseLib": { + "type": "Transitive", + "resolved": "5.4.20", + "contentHash": "0bXgYxbCEN2Ixp3kiFEhyw+RASeFQeg/ww+lbMt7if6XMeVS60eg6epNsMA8Jbx57dmNOzNevkKKw8mP8SUMqw==" + }, + "HarmonyX": { + "type": "Transitive", + "resolved": "2.7.0", + "contentHash": "lec/SkduQspMa3Pi/nM/NSvA84Za8HCCWA8ybdToKiTqnBZa+JC5g6rxoFQCg/1vNuYcvjy77edePZzIEsRmvw==", + "dependencies": { + "MonoMod.RuntimeDetour": "21.12.13.1" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.11.0", + "contentHash": "MhN9wpvL1Es4hfIYslrsVgIsUdDFxuOBTbAl9mGIMvFo8PP5CLf4DrGdNrfwWSyn0lqi6sssm+I/qkkLL3MU+g==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.11.0", + "contentHash": "BXBHYnwSOljKute8UYCMXOLAdB7awxEMppPSpqOF1ZxLD/W/OH+vTBgFaXA7q0aditJTVXSMVenel+wOkEIyNg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.11.0", + "contentHash": "AKjwKpyRBwoQFQmpcchtoYlfJEBlaY/40uxBRi+qXP4NR7xT8t+XTJtOyEyumfvfJcH5pASs75LwNdXYRr4XNA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.11.0" + } + }, + "Mono.Cecil": { + "type": "Transitive", + "resolved": "0.11.4", + "contentHash": "IC1h5g0NeJGHIUgzM1P82ld57knhP0IcQfrYITDPXlNpMYGUrsG5TxuaWTjaeqDNQMBDNZkB8L0rBnwsY6JHuQ==" + }, + "MonoMod.RuntimeDetour": { + "type": "Transitive", + "resolved": "21.12.13.1", + "contentHash": "65mB+bHjT6UCGCgO+/pYhpuGPf96rQ1Whwkut/x7cqVIW0DTndDFyWc/3bngzhnY/Y6IYtBMlXsU2ATq+CxpHg==", + "dependencies": { + "Mono.Cecil": "0.11.4", + "MonoMod.Utils": "21.12.13.1", + "System.Reflection.TypeExtensions": "4.7.0" + } + }, + "MonoMod.Utils": { + "type": "Transitive", + "resolved": "21.12.13.1", + "contentHash": "/H+0RMWqx/D1/fSuY5jhY6GFvqhdYdlnI7J3IfL8P6y4nJkoZDxqts6+RxWWOz4pbnJdWnxSjS8XG+Lq6rUi7w==", + "dependencies": { + "Mono.Cecil": "0.11.4", + "System.Reflection.TypeExtensions": "4.7.0" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "VybpaOQQhqE6siHppMktjfGBw1GCwvCqiufqmP8F1nj7fTUNtW35LOEt3UZTEsECfo+ELAl/9o9nJx3U91i7vA==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + } + } + } +} \ No newline at end of file From fd00452892d01d8d1ae51941e8293ef7baf58081 Mon Sep 17 00:00:00 2001 From: Zeldris <80939130+zeldrisho@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:23:44 +0700 Subject: [PATCH 2/5] feat: support ordered font coverage fallbacks --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 1 + README.md | 51 ++++++++++----- docs/configuration.md | 23 ++++++- docs/development.md | 10 +-- docs/plan.md | 22 ------- src/FontRules.cs | 18 +++++ src/PluginSettings.cs | 2 + src/UnityVietnameseFontSyncPlugin.cs | 98 +++++++++++++++++++++------- tests/ConfigurationTests.cs | 22 +++++++ 11 files changed, 178 insertions(+), 73 deletions(-) delete mode 100644 docs/plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17af13..c5c996c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: "10.0.402" + dotnet-version: "10.0.112" cache: true cache-dependency-path: "**/packages.lock.json" diff --git a/AGENTS.md b/AGENTS.md index 28c50a1..149df59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Toolchain -- Use stable .NET 10 via `global.json` and Python 3 for packaging checks. +- Use stable .NET 10 via `global.json`, Python 3 for packaging checks, and `fd` for file discovery. - Restore with `dotnet restore UnityVietnameseFontSync.slnx --locked-mode`. - Stop and report missing development tools or dependencies. diff --git a/CHANGELOG.md b/CHANGELOG.md index 43dc4c3..462b4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,5 +12,6 @@ and this project uses [Semantic Versioning](https://semver.org/). - Vietnamese glyph fallback for supported Unity Mono games using TextMeshPro. - Optional font synchronization to keep accented and unaccented letters visually consistent. - Multiple font profiles, ordered matching rules, and exclusions for fonts such as icons and symbols. +- Ordered fallback font files within a profile, with required glyph coverage checked across the complete chain. - Configurable font files, atlas settings, and support for late-loaded game fonts. - Warnings for missing font files, unsupported TMP APIs, and characters the selected font cannot provide. diff --git a/README.md b/README.md index d8b562f..5864c26 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,9 @@ A configurable **BepInEx 5 plugin for Unity Mono games using TextMeshPro**. Add This plugin does not translate text. It can work alongside XUnity.AutoTranslator or an existing Vietnamese translation. -## Status +## Compatibility -**Development build; in-game validation is pending.** Automated tests and API-surface checks against two different game assembly sets pass. They do not verify native font generation or rendering. - -- Initial runtime target: Windows, BepInEx 5, Unity Mono, compatible TextMeshPro. +- Windows, BepInEx 5, Unity Mono, and compatible TextMeshPro APIs. - No game-specific font mappings or paths are built in. - BepInEx 6, IL2CPP, legacy Unity UI, NGUI, and UI Toolkit are not supported. - TMP APIs are discovered at runtime; missing required capabilities disable font changes with a log message. This is not a guarantee of compatibility with every Unity/TMP version. @@ -40,39 +38,55 @@ There is no need for a separate BepInEx-only run before installing this plugin. The initial configuration makes **no font changes**. No game assemblies or proprietary font files are bundled. -## Quick configuration +## Worked configuration example -Enable `Diagnostics = true` to log TMP font asset names, or optionally inspect them with UnityExplorer. Match font asset names, not component names. +The generated config starts with no matching rules, so it makes no font changes. Close the game before editing the file, then restart it. Enable diagnostics to find the TMP font asset name in `BepInEx/LogOutput.log`; match the asset name, not the text component name. -Example for a fictional `BodyText SDF` asset: +This complete example synchronizes one fictional asset named `MainMenu SDF`, using a primary font plus a secondary font for missing characters. Replace the filenames and exact asset name with values available in your installation. Put both licensed font files in the plugin's `Fonts/` directory. **Synchronize replaces the matched font's characters in memory**; do not broaden the match to icon or unrelated fonts. ```ini +[General] +Enabled = true +Diagnostics = true +ScanInterval = 2 +RetryInterval = 30 + [Fonts] -Profiles = body +Profiles = menu +CustomCharacters = -[Font.body] +[Font.menu] File = MyVietnameseFont.ttf +FallbackFiles = CoverageFont.ttf +SamplingSize = 36 +Padding = 4 +AtlasSize = 2048 +MultiAtlas = true [Rules] -Order = body-text +Order = menu-text Exclude = Icons, Symbols -[Rule.body-text] -Profile = body +[Rule.menu-text] +Profile = menu Match = Exact -Pattern = BodyText SDF -Mode = Fallback +Pattern = MainMenu SDF +Mode = Synchronize ``` -Place the font in the plugin's `Fonts/` directory, select a filename from Windows Fonts, or specify an absolute path. Use a font you are licensed to use that covers all required characters. +`FallbackFiles` supplies characters missing from the primary file, while keeping its style for available characters. Coverage must be complete across the ordered files; otherwise the profile is disabled. Leave this setting empty when the primary font covers the whole preset. + +Keep `Synchronize` when replacing incorrect existing glyphs or making accented and unaccented letters use the same replacement style. Use `Mode = Fallback` instead if you only need missing-character coverage: it retains the original glyphs and cannot replace incorrect ones. Both modes preserve existing fallback chains and leave unmatched fonts unchanged. Restart without the plugin to restore the game's original assets. -See [configuration](docs/configuration.md) for multiple profiles, exclusions, synchronization, and atlas settings. +See [configuration](docs/configuration.md) for multiple profiles, matching modes, exclusions, and atlas settings. **Synchronization clears the matched font's character table in memory.** Use narrow rules and check symbols, untranslated text, and layout. Restart to recover. Matching outlines, shadows, spacing, and line breaks is not guaranteed. ## With XUnity.AutoTranslator -When this plugin owns TMP fonts, start with XUnity's competing font settings empty: +If XUnity.AutoTranslator's `FallbackFontTextMeshPro` already renders Vietnamese with acceptable styling, you don't need this plugin. Use it when you need targeted font replacement, consistent accented/unaccented styling, or different replacement fonts for different text groups. + +Only when migrating TMP font handling to this plugin, start with XUnity's competing font settings empty: ```ini [Behaviour] @@ -87,12 +101,15 @@ Keep translation enabled. This plugin neither edits XUnity configuration nor rew | Symptom | Check | |---|---| | Nothing changes | `Rules.Order`, exact asset names, `Enabled`, and profile `File`; rules are empty by default | +| Startup logs appear, but no later font discovery | If the game removes or disables BepInEx's manager object, try `[Chainloader] HideManagerGameObject = true` in `BepInEx/config/BepInEx.cfg`, then restart | | Profile disabled | Log warnings for missing files, API failures, or missing `U+...` characters; try another font or larger atlas | | Accents look wrong | Fallback cannot replace an existing incorrect glyph; consider narrowly targeted synchronization | | Mixed styles | Fallback preserves available original glyphs; synchronization changes the whole matched font group | | Icons/other languages disappear | Avoid broad synchronization and verify preserved fallback coverage | | Combining marks fail | Add marks to `CustomCharacters`; test the source font and TMP shaping behavior | +`HideManagerGameObject` is a BepInEx setting, not a plugin setting; do not add it to this plugin's config. It hides BepInEx's manager object from scene discovery to help protect it from game cleanup. It affects all plugins on that manager and is not required in every game. Keep it enabled in installations where it restores ongoing scanning. + To disable, set `Enabled = false` and restart. To uninstall, close the game and remove the plugin DLL. ## Development diff --git a/docs/configuration.md b/docs/configuration.md index d2b59f9..40a99db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,6 +24,7 @@ The preset covers printable ASCII and uppercase/lowercase precomposed Vietnamese | Key | Default | Allowed values | |---|---|---| | File | empty | Font filename, relative path, or absolute path; empty disables profile | +| FallbackFiles | empty | Ordered comma-separated font files covering glyphs missing from File; same path resolution as File | | SamplingSize | 36 | 8–128 | | Padding | 4 | 1–32 | | AtlasSize | 2048 | 256, 512, 1024, 2048, 4096 | @@ -31,7 +32,11 @@ The preset covers printable ASCII and uppercase/lowercase precomposed Vietnamese Relative paths search `/Fonts/` first, then the OS Fonts directory. Absolute paths are used directly. Paths must be valid on the machine running the game; a Linux `/mnt/c/...` path is not a Windows runtime path. -Profiles must successfully add the entire preset plus custom characters. Missing glyphs or atlas population failures disable the profile and log missing Unicode values where TMP supplies them. Mapped game fonts are left alone. Files are loaded once at startup; restart to retry after fixing a file. +Profiles must successfully add the entire preset plus custom characters across `File` and `FallbackFiles` combined. Each fallback file is preloaded with only the characters still missing from earlier files. Later files are not loaded once coverage is complete. All generated files use the profile's atlas settings; the primary asset links to the generated fallback assets in order. No links back to game fonts are added. + +Example: `File = PrimaryFont.ttf` with `FallbackFiles = CoverageFont.ttf` keeps the primary style wherever available and uses the second font for missing characters. A character supplied by another font may look different. This does not guarantee combining-mark shaping. + +If coverage remains incomplete, a needed file is missing, or TMP cannot report which characters failed, the entire profile is disabled and mapped game fonts are left alone. Missing Unicode values are logged where TMP supplies them. Files are loaded once at startup; restart to retry after fixing a file. ## Rules: `[Rule.]` @@ -47,6 +52,18 @@ Profiles must successfully add the entire preset plus custom characters. Missing - Fallback appends the profile after existing fallbacks; original glyphs remain unchanged. - Synchronize inserts the profile first, freezes the original atlas, clears its whole character table, and rebuilds lookup data. Existing fallbacks remain afterward. - Synchronize can break symbols and non-Latin text absent from the replacement and preserved fallbacks. There is no automatic icon detection or serif/sans classification. +- Keep Synchronize for narrow rules intended to replace incorrect existing glyphs or unify accented/unaccented styling. Fallback cannot replace glyphs already present in the original font. + +## BepInEx manager lifecycle + +If startup logs appear but font discovery stops before game assets load, the game may be removing or disabling BepInEx's manager object. Close the game and try this in `BepInEx/config/BepInEx.cfg` (not the plugin config): + +```ini +[Chainloader] +HideManagerGameObject = true +``` + +Restart afterward. This hides BepInEx's manager object from scene discovery to help protect it from game cleanup; it affects all plugins on that manager. It is not required in every game. Keep it enabled where it restores scanning, and confirm the log shows game-font discovery and applied rules. ## Multiple-profile example @@ -85,9 +102,9 @@ Names are illustrative, not built-in game mappings. Start with exact Fallback ru Manual C# font changes affect the current process; they are not plugin configuration. Disable any saved/startup font script, close the game completely, and start with this plugin to generate its configuration. Recreate the intended font-to-asset mappings as profiles and rules; there are no existing plugin settings to migrate. -For example, the previously tested two-style setup maps `MortyOld` asset-name fragments to `palab.ttf` (Palatino Linotype Bold) and `Feruka` fragments to `bahnschrift.ttf` (Bahnschrift), using Synchronize for both. Those are optional user mappings, not built-in defaults or recommendations for every game. Use separate rules for each group instead of an `else` rule replacing all remaining fonts. Verify the files exist and pass glyph preflight on the target machine. +Create a separate profile and narrow matching rule for each font group that needs a different style. Avoid a catch-all rule that sends every other font to one replacement; icon, symbol, and non-Latin assets may need their original glyphs. Verify the font files exist and pass glyph preflight on the target machine. -Use Fallback when only missing-character coverage is needed. Use Synchronize when a matched font group should render both accented and ordinary letters through the selected profile, including games whose existing fallback already provides Vietnamese but looks visually inconsistent. +Use Fallback when only missing-character coverage is needed. Use Synchronize when a matched font group should render both accented and ordinary letters through the selected profile, including when the original font already has Vietnamese glyphs but their appearance is inconsistent. ## Recovery diff --git a/docs/development.md b/docs/development.md index b7fe67d..ed92fb2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ ## Toolchain and layout - Stable .NET 10 SDK, Git, and Python 3 for release packaging/tests. -- `global.json` accepts installed stable .NET 10 SDK feature bands; CI pins SDK 10.0.402, the latest stable .NET 10 SDK reported when configured. Local checks used 10.0.112. +- `global.json` accepts installed stable .NET 10 SDK feature bands; CI pins SDK 10.0.112 to match the locally validated toolchain. - `src/`: the plugin project; `tests/`: Unity-independent tests; `scripts/`: release packaging. - Public compile-time packages and lock files make clean builds independent of game installations. Never commit or distribute game assemblies or proprietary fonts. @@ -50,11 +50,11 @@ Generated assets/pages are pinned. Best-effort restoration runs at plugin destru The working starting point was C# run inside UnityExplorer, not an installed version of this plugin or an existing plugin configuration: -- In Bō (Unity 2022.3.0f1, Mono), user-reported menu/settings success used Palatino Linotype Bold (`palab.ttf`) for `MortyOld` and Bahnschrift (`bahnschrift.ttf`) for `Feruka`, with sampling size 36, padding 4, 2048-square dynamic replacement atlases, and ASCII/Vietnamese preloading. -- The successful synchronization approach froze matched original assets to Static, cleared their character tables, rebuilt lookup data, and used replacement fallbacks. The plugin implements that approach through explicit rules while preserving original fallback chains. +- User-reported menu/settings tests in Unity Mono used separate serif and sans-serif replacement profiles, sampling size 36, padding 4, 2048-square dynamic atlases, and ASCII/Vietnamese preloading. These manual results do not establish plugin runtime support. +- The manual synchronization approach froze matched original assets to Static, cleared their character tables and fallback lists, and assigned replacement fallbacks. The plugin offers narrow explicit Synchronize rules but deliberately preserves existing fallback chains and leaves unmatched fonts unchanged. - Missing glyphs and visually incorrect existing glyphs are different problems. Removing only lookup entries did not resolve the manual failure; a dynamic original font could add glyphs again. A fallback alone also mixed accented/unaccented styles. -- A tested Trebuchet replacement produced missing-character boxes. That observation is not a universal coverage claim about every font release; preflight each actual file. Never assume an atlas size guarantees sufficient capacity. -- Feather's Edge (Unity 6000.0.49f1, Mono) already logged successful XUnity fallback creation; style consistency remains a separate use case. No complete replacement mapping or new-plugin rendering result has been verified there. +- A tested replacement font produced missing-character boxes. That observation is not a universal coverage claim about every font release; preflight each actual file. Never assume an atlas size guarantees sufficient capacity. +- Other Unity Mono testing logged successful XUnity fallback creation, but no complete replacement mapping or new-plugin rendering result has been verified in that environment. - TMP package versions cannot be established from those logs alone. Original metrics/material behavior, all font names, gameplay screens beyond the reported menus, long-term XUnity interactions, and performance remain unverified. Do not carry forward speculative explanations or claims of perfect shader preservation. A texture PNG alone is not a portable TMP font asset: glyph mappings, metrics, and other font data are also required. Runtime-generated fonts are not exported by this plugin. Restarting clears manual in-memory changes; disable startup scripts before testing the plugin independently. diff --git a/docs/plan.md b/docs/plan.md deleted file mode 100644 index 0a8a18b..0000000 --- a/docs/plan.md +++ /dev/null @@ -1,22 +0,0 @@ -# Remaining work - -## Runtime validation - -- [ ] Run the new runtime-adapter plugin in multiple Unity Mono games; record actual BepInEx, Unity, and TMP versions. -- [ ] Verify startup and late TMP assembly loading, missing/stripped capabilities, and native font creation failures. -- [ ] Validate Fallback/Synchronize independently, failed profile safety, symbols, combining marks, original-language fallbacks, rich text, and weights. -- [ ] Compare outlines, shadows, spacing, wrapping, and clipping at different sizes. -- [ ] Test scenes, late assets, inactive text activation, retries, and long-session multi-atlas memory behavior. -- [ ] Verify indirect fallback users refresh; extend refresh propagation if required. -- [ ] Verify XUnity coexistence and measure discovery/refresh costs. -- [ ] Add Unity integration coverage for mutation rollback and cleanup; API-surface and fake-adapter tests do not verify native rendering. - -## Release readiness - -- [ ] Confirm redistribution rights for any future third-party resources; plugin code and documentation now use MIT. -- [ ] Run the workflows on GitHub after a repository remote is configured; local checks cannot verify hosted artifact/release permissions. -- [ ] Validate the release archive and runtime behavior before pushing a version tag; the workflow publishes a normal release automatically. -- [ ] Add changelog tag/comparison links once the remote and first release exist. -- [ ] Recheck stable BepInEx.Templates generation with newer SDKs; the existing project does not need template regeneration. - -The two permanent guides are [configuration.md](configuration.md) and [development.md](development.md). Architecture notes are consolidated into development rather than maintained as a third guide. diff --git a/src/FontRules.cs b/src/FontRules.cs index 423daae..26b4a5c 100644 --- a/src/FontRules.cs +++ b/src/FontRules.cs @@ -52,6 +52,24 @@ public static string ResolvePath(string file, string fontsDirectory, string cust return Path.Combine(fontsDirectory, file); } + public static uint[] RemainingCharacters(uint[] requested, bool complete, uint[] missing) + { + if (complete) return new uint[0]; + if (missing == null || missing.Length == 0) + throw new InvalidOperationException("Glyph population failed without a missing-character list; coverage cannot be verified."); + var remaining = new HashSet(); + foreach (uint code in missing) + { + if (Array.IndexOf(requested, code) < 0) + throw new InvalidOperationException("Glyph population returned an unexpected missing character."); + remaining.Add(code); + } + var result = new uint[remaining.Count]; + remaining.CopyTo(result); + Array.Sort(result); + return result; + } + public static uint[] Characters(string extra) { var text = new StringBuilder(); diff --git a/src/PluginSettings.cs b/src/PluginSettings.cs index f3f5378..ab354c1 100644 --- a/src/PluginSettings.cs +++ b/src/PluginSettings.cs @@ -7,6 +7,7 @@ namespace UnityVietnameseFontSync internal sealed class FontProfile { public string Id, File; + public string[] FallbackFiles; public int Size, Padding, Atlas; public bool MultiAtlas; } @@ -36,6 +37,7 @@ public PluginSettings(ConfigFile config) { Id = id, File = config.Bind(section, "File", "", "Font filename or absolute path. Empty disables this profile. Relative paths search plugin Fonts then Windows Fonts.").Value, + FallbackFiles = FontRules.Split(config.Bind(section, "FallbackFiles", "", "Ordered comma-separated font files used for glyphs missing from File. Full combined coverage is required.").Value), Size = config.Bind(section, "SamplingSize", 36, new ConfigDescription("Font sampling size.", new AcceptableValueRange(8, 128))).Value, Padding = config.Bind(section, "Padding", 4, new ConfigDescription("SDF padding.", new AcceptableValueRange(1, 32))).Value, Atlas = config.Bind(section, "AtlasSize", 2048, "Square atlas size: 256, 512, 1024, 2048, or 4096.").Value, diff --git a/src/UnityVietnameseFontSyncPlugin.cs b/src/UnityVietnameseFontSyncPlugin.cs index 4eb1caa..8126c17 100644 --- a/src/UnityVietnameseFontSyncPlugin.cs +++ b/src/UnityVietnameseFontSyncPlugin.cs @@ -20,19 +20,43 @@ public sealed class UnityVietnameseFontSyncPlugin : BaseUnityPlugin private readonly HashSet _reported = new HashSet(); private readonly Dictionary _retry = new Dictionary(); private float _nextScan; - private bool _ready, _assetsInstalled, _apiWarningLogged; + private bool _ready, _assetsInstalled, _apiWarningLogged, _firstFontScanLogged; private void Awake() { - try { _settings = new PluginSettings(Config); } + Logger.LogInfo("Awake entered; loading configuration."); + try + { + _settings = new PluginSettings(Config); + Logger.LogInfo("Configuration loaded (Enabled=" + _settings.Enabled + ", profiles=" + _settings.Profiles.Count + + ", rules=" + _settings.Rules.Count + ", Diagnostics=" + _settings.Diagnostics + ")."); + InitializeRuntime(); + } catch (Exception ex) { Logger.LogError("Invalid configuration; no fonts will be changed: " + ex); } } private void Start() { - if (_settings == null || !_settings.Enabled) return; + Logger.LogInfo("Start entered."); + InitializeRuntime(); + } + + private void InitializeRuntime() + { + if (_ready) return; + if (_settings == null) + { + Logger.LogError("Configuration unavailable; plugin will not scan fonts."); + return; + } + if (!_settings.Enabled) + { + Logger.LogInfo("Plugin is disabled in configuration."); + return; + } _ready = true; SceneManager.sceneLoaded += OnSceneLoaded; + Logger.LogInfo("Runtime scanning initialized."); Scan(); } @@ -47,30 +71,43 @@ private void Update() private void Build(FontProfile profile) { string directory = Path.Combine(Path.GetDirectoryName(Info.Location), "Fonts"); - string path = FontRules.ResolvePath(profile.File, Environment.GetFolderPath(Environment.SpecialFolder.Fonts), directory); - if (path == null) return; - if (!File.Exists(path)) throw new FileNotFoundException("Font file not found", path); + if (string.IsNullOrWhiteSpace(profile.File)) return; + var files = new List { profile.File }; + files.AddRange(profile.FallbackFiles); int start = _owned.Count; try { - Font source = new Font(path); - Own(source); - Object asset = (Object)_tmp.Create(source, profile.Size, profile.Padding, profile.Atlas, profile.MultiAtlas); - if (asset == null) throw new InvalidOperationException("TMP font creation returned null."); - Own(asset); - asset.name = MyPluginInfo.PLUGIN_GUID + "." + profile.Id; - Own(_tmp.Material(asset) as Object); - bool complete; - uint[] missing; - try { complete = _tmp.AddCharacters(asset, _settings.Characters, out missing); } - finally { OwnTextures(asset); } - if (!complete) + Object root = null; + uint[] remaining = _settings.Characters; + for (int i = 0; i < files.Count && remaining.Length > 0; i++) + { + string path = FontRules.ResolvePath(files[i], Environment.GetFolderPath(Environment.SpecialFolder.Fonts), directory); + if (!File.Exists(path)) throw new FileNotFoundException("Font file not found", path); + Font source = new Font(path); + Own(source); + Object asset = (Object)_tmp.Create(source, profile.Size, profile.Padding, profile.Atlas, profile.MultiAtlas); + if (asset == null) throw new InvalidOperationException("TMP font creation returned null."); + Own(asset); + asset.name = MyPluginInfo.PLUGIN_GUID + "." + profile.Id + (i == 0 ? "" : ".fallback" + i); + Own(_tmp.Material(asset) as Object); + // Only generated assets are linked. Never link back to game fonts. + _tmp.SetFallbacks(asset, _tmp.NewFallbacks()); + if (root == null) root = asset; + else _tmp.Fallbacks(root).Add(asset); + bool complete; + uint[] missing; + try { complete = _tmp.AddCharacters(asset, remaining, out missing); } + finally { OwnTextures(asset); } + remaining = FontRules.RemainingCharacters(remaining, complete, missing); + Logger.LogInfo("Profile '" + profile.Id + "' file '" + files[i] + "': " + remaining.Length + " required characters remain."); + } + if (remaining.Length > 0) { var codes = new List(); - foreach (uint code in missing ?? new uint[0]) codes.Add("U+" + code.ToString("X4")); + foreach (uint code in remaining) codes.Add("U+" + code.ToString("X4")); throw new InvalidOperationException("Required glyphs could not be added: " + string.Join(", ", codes.ToArray())); } - _profiles.Add(profile.Id, asset); + _profiles.Add(profile.Id, root); Logger.LogInfo("Loaded profile '" + profile.Id + "' with " + _settings.Characters.Length + " required characters."); } catch @@ -100,7 +137,11 @@ private void Scan() if (_tmp == null) { // TMP may be loaded after this plugin starts. Retry discovery, without log spam. - try { _tmp = TmpRuntime.Discover(typeof(Font)); } + try + { + _tmp = TmpRuntime.Discover(typeof(Font)); + Logger.LogInfo("TMP API ready: " + _tmp.FontType.Assembly.GetName().Name + " / " + _tmp.FontType.FullName + "."); + } catch (Exception ex) { if (!_apiWarningLogged) Logger.LogWarning("TMP API unavailable; no fonts changed. Discovery will retry: " + ex); @@ -117,10 +158,18 @@ private void Scan() _reported.RemoveWhere(font => font == null); foreach (Object font in new List(_retry.Keys)) if (font == null) _retry.Remove(font); // Pin atlas pages added by TMP since preflight, not just the initial pages. - foreach (Object profile in _profiles.Values) if (profile != null) OwnTextures(profile); + foreach (Object asset in new List(_owned)) + if (asset != null && _tmp.FontType.IsInstanceOfType(asset)) OwnTextures(asset); if (_profiles.Count == 0 && !_settings.Diagnostics) return; var changed = new HashSet(); - foreach (Object font in Resources.FindObjectsOfTypeAll(_tmp.FontType)) + Object[] fonts = Resources.FindObjectsOfTypeAll(_tmp.FontType); + if (!_firstFontScanLogged) + { + Logger.LogInfo("First TMP font scan found " + fonts.Length + " font assets; profiles ready=" + _profiles.Count + + ", configured rules=" + _settings.Rules.Count + "."); + _firstFontScanLogged = true; + } + foreach (Object font in fonts) { if (font == null || _owned.Contains(font) || _patched.ContainsKey(font)) continue; if (_settings.Diagnostics && _reported.Add(font)) Logger.LogInfo("Discovered TMP font: " + font.name); @@ -197,7 +246,8 @@ private void OnDestroy() // Used materials can remain referenced by cached submeshes. Restart to reclaim them. if (restored && !_assetsInstalled) { - foreach (Object asset in _profiles.Values) if (asset != null) OwnTextures(asset); + foreach (Object asset in new List(_owned)) + if (asset != null && _tmp.FontType.IsInstanceOfType(asset)) OwnTextures(asset); for (int i = _owned.Count - 1; i >= 0; i--) if (_owned[i] != null) Destroy(_owned[i]); } } diff --git a/tests/ConfigurationTests.cs b/tests/ConfigurationTests.cs index 9480cb3..e4f2871 100644 --- a/tests/ConfigurationTests.cs +++ b/tests/ConfigurationTests.cs @@ -89,6 +89,28 @@ public void ConfigurationPreservesExplicitOrder() Assert.Equal(PatchMode.Synchronize, settings.Rules[1].Mode); } + [Fact] + public void ProfileFallbackFilesPreserveOrderAndDefaultToEmpty() + { + Assert.Empty(Load("").Profiles[0].FallbackFiles); + var settings = Load("[Font.default]\nFile = Primary.ttf\nFallbackFiles = Secondary.ttf, Third.ttf, Secondary.ttf\n"); + Assert.Equal("Primary.ttf", settings.Profiles[0].File); + Assert.Equal(new[] { "Secondary.ttf", "Third.ttf" }, settings.Profiles[0].FallbackFiles); + } + + [Fact] + public void CoverageChainRequiresAllRemainingCharacters() + { + uint[] requested = { 65, 0x1EF7, 0x1EF6 }; + uint[] remaining = FontRules.RemainingCharacters(requested, false, new uint[] { 0x1EF7 }); + Assert.Equal(new uint[] { 0x1EF7 }, remaining); + Assert.Equal(remaining, FontRules.RemainingCharacters(remaining, false, remaining)); + Assert.Empty(FontRules.RemainingCharacters(remaining, true, new uint[0])); + Assert.Throws(() => FontRules.RemainingCharacters(requested, false, null)); + Assert.Throws(() => FontRules.RemainingCharacters(requested, false, new uint[0])); + Assert.Throws(() => FontRules.RemainingCharacters(requested, false, new uint[] { 999 })); + } + private static PluginSettings Load(string text) { string file = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".cfg"); From 463a404f1726fca5cff4f626dbbdee630b4fe99f Mon Sep 17 00:00:00 2001 From: Zeldris <80939130+zeldrisho@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:31:05 +0700 Subject: [PATCH 3/5] chore: prepare v1.0.0 release --- CHANGELOG.md | 2 ++ README.md | 8 +------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 462b4e9..2eb83f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project uses [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.0] - 2026-10-15 + ### Added - Vietnamese glyph fallback for supported Unity Mono games using TextMeshPro. diff --git a/README.md b/README.md index 5864c26..7ec88f2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This plugin does not translate text. It can work alongside XUnity.AutoTranslator ## Installation 1. Install [BepInEx 5 for Unity Mono](https://docs.bepinex.dev/articles/user_guide/installation/index.html). -2. Extract the release archive into `/BepInEx/`, or [build from source](docs/development.md) and place the DLL at: +2. Extract the release archive into `/BepInEx/`. The plugin DLL should be at: ```text /BepInEx/plugins/zeldrisho.UnityVietnameseFontSync/UnityVietnameseFontSync.dll ``` @@ -112,12 +112,6 @@ Keep translation enabled. This plugin neither edits XUnity configuration nor rew To disable, set `Enabled = false` and restart. To uninstall, close the game and remove the plugin DLL. -## Development - -See [development](docs/development.md) for build/test commands, runtime design, and release steps. Notable changes are recorded in [CHANGELOG.md](CHANGELOG.md). - -CI checks formatting, locked restore, compilation, tests, and packaging logic. A matching version tag publishes the release ZIP after those checks pass. - ## License [MIT](LICENSE). The license covers this plugin and its documentation, not third-party fonts or game assemblies. Do not redistribute proprietary system fonts or game assets. From e534d71d4b053089ae4282355a7c66f004d5c60e Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:41:36 +0000 Subject: [PATCH 4/5] docs: document font sync methods, tests, and release packaging --- scripts/package.py | 4 +++ scripts/test_package.py | 2 ++ src/FontRules.cs | 18 ++++++++++ src/PluginSettings.cs | 6 ++++ src/TmpRuntime.cs | 54 ++++++++++++++++++++++++++++ src/UnityVietnameseFontSyncPlugin.cs | 39 ++++++++++++++++++++ tests/ConfigurationTests.cs | 33 +++++++++++++++++ tests/TmpRuntimeTests.cs | 30 ++++++++++++++++ 8 files changed, 186 insertions(+) diff --git a/scripts/package.py b/scripts/package.py index fed0db6..dc09dcf 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -14,6 +14,7 @@ def release_notes(changelog, version): + """Return notes for a dated release with at least one change entry, or raise ValueError.""" match = re.search( rf"^## \[{re.escape(version)}\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*\n(.*?)(?=^## |\Z)", changelog, @@ -29,6 +30,7 @@ def release_notes(changelog, version): def package(root, tag, dll, output, repository_url=None): + """Validate release metadata and inputs, write an allowlisted deterministic ZIP and notes, and return the archive path.""" if not re.fullmatch(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", tag): raise ValueError("Use a stable SemVer tag such as v1.0.0.") version = tag[1:] @@ -61,6 +63,7 @@ def package(root, tag, dll, output, repository_url=None): if source == root / "README.md": # Guides stay in the repository, not the install archive. Avoid broken local links. def documentation_link(match): + """Convert a guide link to the release-tag URL, or to plain text when no repository URL is available.""" label, path = match.groups() if repository_url: return f"[{label}]({repository_url.rstrip('/')}/blob/{tag}/{path})" @@ -73,6 +76,7 @@ def documentation_link(match): def main(): + """Parse release arguments, package the inputs, and report validation errors with a failing exit status.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--tag", required=True) parser.add_argument("--dll", type=Path, required=True) diff --git a/scripts/test_package.py b/scripts/test_package.py index 9fb222b..980d72a 100644 --- a/scripts/test_package.py +++ b/scripts/test_package.py @@ -8,6 +8,7 @@ class PackageTests(unittest.TestCase): def test_notes_require_dated_nonempty_release(self): + """Verify dated release notes are extracted and missing, invalid, or empty entries are rejected.""" self.assertEqual("### Added\n- Example.\n", release_notes( "## [1.0.0] - 2026-10-15\n\n### Added\n- Example.\n\n## [0.9.0] - 2026-09-01\n", "1.0.0")) for text in ("## [Unreleased]\n- Example.", "## [1.0.0] - 2026-99-99\n- Example.", @@ -16,6 +17,7 @@ def test_notes_require_dated_nonempty_release(self): release_notes(text, "1.0.0") def test_exact_layout_version_and_reproducibility(self): + """Verify archive contents, reproducibility, guide links, and rejection of invalid or mismatched tags.""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / "src").mkdir() diff --git a/src/FontRules.cs b/src/FontRules.cs index 26b4a5c..0012126 100644 --- a/src/FontRules.cs +++ b/src/FontRules.cs @@ -15,6 +15,9 @@ public sealed class FontRule public string Pattern; public MatchKind Match; public PatchMode Mode; + /// + /// Tests the asset name using the configured case-insensitive match kind. + /// public bool Matches(string name) { if (Match == MatchKind.All) return true; @@ -27,6 +30,9 @@ public bool Matches(string name) public static class FontRules { + /// + /// Trims a comma-separated list, omits empty entries, and preserves the first occurrence of each value. + /// public static string[] Split(string value) { var result = new List(); @@ -35,6 +41,9 @@ public static string[] Split(string value) return result.ToArray(); } + /// + /// Returns the first matching rule, or null when the name is excluded or no rule matches. + /// public static FontRule Select(string name, IEnumerable rules, string[] exclusions) { foreach (string exclusion in exclusions) @@ -43,6 +52,9 @@ public static FontRule Select(string name, IEnumerable rules, string[] return null; } + /// + /// Returns an absolute path unchanged, otherwise prefers an existing custom font before the system font directory. + /// public static string ResolvePath(string file, string fontsDirectory, string customDirectory) { if (string.IsNullOrWhiteSpace(file)) return null; @@ -52,6 +64,9 @@ public static string ResolvePath(string file, string fontsDirectory, string cust return Path.Combine(fontsDirectory, file); } + /// + /// Returns sorted, unique missing codes; rejects failed population results whose coverage cannot be verified. + /// public static uint[] RemainingCharacters(uint[] requested, bool complete, uint[] missing) { if (complete) return new uint[0]; @@ -70,6 +85,9 @@ public static uint[] RemainingCharacters(uint[] requested, bool complete, uint[] return result; } + /// + /// Returns sorted, unique Unicode scalars for printable ASCII, precomposed Vietnamese, and custom characters; rejects unpaired surrogates. + /// public static uint[] Characters(string extra) { var text = new StringBuilder(); diff --git a/src/PluginSettings.cs b/src/PluginSettings.cs index ab354c1..c220bd3 100644 --- a/src/PluginSettings.cs +++ b/src/PluginSettings.cs @@ -21,6 +21,9 @@ internal sealed class PluginSettings public float ScanInterval, RetryInterval; public bool Enabled, Diagnostics; + /// + /// Binds configuration defaults and loads ordered profiles and rules, rejecting invalid IDs, atlas sizes, modes, and rule references. + /// public PluginSettings(ConfigFile config) { Enabled = config.Bind("General", "Enabled", true, "Enable plugin. Restart after configuration changes.").Value; @@ -65,6 +68,9 @@ public PluginSettings(ConfigFile config) } } + /// + /// Rejects profile or rule IDs containing characters other than letters, digits, underscores, or hyphens. + /// private static void ValidateId(string id) { foreach (char c in id) diff --git a/src/TmpRuntime.cs b/src/TmpRuntime.cs index eb97a84..1afba88 100644 --- a/src/TmpRuntime.cs +++ b/src/TmpRuntime.cs @@ -14,6 +14,9 @@ internal sealed class TmpRuntime private readonly PropertyInfo _characters, _fallbacks, _mode, _textures, _textFont; private readonly MemberInfo _material; + /// + /// Validates the reflected TMP API and enum values before allowing font creation or mutation. + /// public TmpRuntime(Type fontType, Type textType, Type unityFontType) { FontType = fontType; @@ -46,6 +49,9 @@ public TmpRuntime(Type fontType, Type textType, Type unityFontType) Enum.Parse(_create.GetParameters()[3].ParameterType, "SDFAA"); } + /// + /// Finds TMP font and text types in a loaded assembly, or throws when no supported API is available. + /// public static TmpRuntime Discover(Type unityFontType) { foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) @@ -57,6 +63,9 @@ public static TmpRuntime Discover(Type unityFontType) throw new NotSupportedException("No loaded TMP_FontAsset/TMP_Text API found. Legacy UI and IL2CPP are not supported."); } + /// + /// Creates a dynamic SDF font with a square atlas, using declared defaults for optional trailing parameters. + /// public object Create(object source, int size, int padding, int atlas, bool multiAtlas) { var parameters = _create.GetParameters(); @@ -68,6 +77,9 @@ public object Create(object source, int size, int padding, int atlas, bool multi return _create.Invoke(null, arguments); } + /// + /// Attempts to populate glyphs and returns the TMP completion result and missing character codes. + /// public bool AddCharacters(object font, uint[] codes, out uint[] missing) { object[] arguments = { codes, null, false }; @@ -76,24 +88,63 @@ public bool AddCharacters(object font, uint[] codes, out uint[] missing) return complete; } + /// + /// Returns the mutable character table of the reflected font asset. + /// public IList Characters(object font) => (IList)_characters.GetValue(font, null); + /// + /// Returns the font asset fallback list, which may be null. + /// public IList Fallbacks(object font) => (IList)_fallbacks.GetValue(font, null); + /// + /// Assigns the font asset fallback list, including a null list when restoring original state. + /// public void SetFallbacks(object font, IList value) => _fallbacks.SetValue(font, value, null); + /// + /// Creates an empty list of the type required by the TMP fallback property. + /// public IList NewFallbacks() => (IList)Activator.CreateInstance(_fallbacks.PropertyType); + /// + /// Returns the font asset atlas population mode. + /// public object Mode(object font) => _mode.GetValue(font, null); + /// + /// Assigns the font asset atlas population mode. + /// public void SetMode(object font, object mode) => _mode.SetValue(font, mode, null); + /// + /// Sets the font asset atlas population mode to Static. + /// public void Freeze(object font) => SetMode(font, Enum.Parse(_mode.PropertyType, "Static")); + /// + /// Returns the atlas textures currently associated with the font asset. + /// public IEnumerable Textures(object font) => (IEnumerable)_textures.GetValue(font, null); + /// + /// Reads the font material through the available reflected property or field. + /// public object Material(object font) => _material is PropertyInfo property ? property.GetValue(font, null) : ((FieldInfo)_material).GetValue(font); + /// + /// Returns the text component primary font asset. + /// public object TextFont(object text) => _textFont.GetValue(text, null); + /// + /// Rebuilds the font asset definition after its tables change. + /// public void ReadDefinition(object font) => _readDefinition.Invoke(font, null); + /// + /// Marks text dirty and forces a mesh update with inactive objects and reparsing enabled. + /// public void Refresh(object text) { _dirty.Invoke(text, null); _refresh.Invoke(text, new object[] { true, true }); } + /// + /// Returns a readable property, optionally requiring write access, or throws for an unsupported API. + /// private static PropertyInfo RequireProperty(Type type, string name, bool writable = false) { PropertyInfo property = type.GetProperty(name); @@ -102,6 +153,9 @@ private static PropertyInfo RequireProperty(Type type, string name, bool writabl return property; } + /// + /// Returns the method with the required parameter types, or throws when the overload is absent. + /// private static MethodInfo RequireMethod(Type type, string name, params Type[] parameters) { return type.GetMethod(name, parameters) ?? throw new NotSupportedException(type.FullName + "." + name + " has no compatible overload."); diff --git a/src/UnityVietnameseFontSyncPlugin.cs b/src/UnityVietnameseFontSyncPlugin.cs index 8126c17..b0b2bc0 100644 --- a/src/UnityVietnameseFontSyncPlugin.cs +++ b/src/UnityVietnameseFontSyncPlugin.cs @@ -22,6 +22,9 @@ public sealed class UnityVietnameseFontSyncPlugin : BaseUnityPlugin private float _nextScan; private bool _ready, _assetsInstalled, _apiWarningLogged, _firstFontScanLogged; + /// + /// Loads configuration and initializes scanning, logging configuration failures without propagating them. + /// private void Awake() { Logger.LogInfo("Awake entered; loading configuration."); @@ -35,12 +38,18 @@ private void Awake() catch (Exception ex) { Logger.LogError("Invalid configuration; no fonts will be changed: " + ex); } } + /// + /// Retries runtime initialization after Unity startup. + /// private void Start() { Logger.LogInfo("Start entered."); InitializeRuntime(); } + /// + /// Subscribes to scene loads and performs an initial scan once valid, enabled settings are available. + /// private void InitializeRuntime() { if (_ready) return; @@ -60,14 +69,23 @@ private void InitializeRuntime() Scan(); } + /// + /// Runs a discovery scan when the configured periodic interval has elapsed. + /// private void Update() { if (!_ready || _settings.ScanInterval <= 0 || Time.unscaledTime < _nextScan) return; Scan(); } + /// + /// Scans for newly loaded font assets after a scene load. + /// private void OnSceneLoaded(Scene scene, LoadSceneMode mode) { Scan(); } + /// + /// Builds a profile with complete glyph coverage, skips an empty primary file, and destroys newly owned assets on failure. + /// private void Build(FontProfile profile) { string directory = Path.Combine(Path.GetDirectoryName(Info.Location), "Fonts"); @@ -117,12 +135,18 @@ private void Build(FontProfile profile) } } + /// + /// Tracks and pins the font asset current atlas textures. + /// private void OwnTextures(Object font) { IEnumerable textures = _tmp.Textures(font); if (textures != null) foreach (object texture in textures) Own(texture as Object); } + /// + /// Pins a non-null asset against unused-asset unloading and tracks it once. + /// private void Own(Object value) { if (value == null || _owned.Contains(value)) return; @@ -130,6 +154,9 @@ private void Own(Object value) _owned.Add(value); } + /// + /// Discovers TMP and profiles, patches matching live fonts with rollback and retry handling, and refreshes directly affected text. + /// private void Scan() { if (!_ready) return; @@ -233,6 +260,9 @@ private void Scan() } } + /// + /// Unsubscribes from scene loads, attempts to restore patched fonts, and destroys generated assets only when none were installed and restoration succeeded. + /// private void OnDestroy() { SceneManager.sceneLoaded -= OnSceneLoaded; @@ -256,12 +286,18 @@ private sealed class Snapshot { private readonly object _mode; private readonly object[] _characters, _fallbacks; + /// + /// Saves the original atlas mode and fallbacks, optionally copying characters for synchronization rollback. + /// public Snapshot(TmpRuntime tmp, Object font, bool saveCharacters) { _mode = tmp.Mode(font); _characters = saveCharacters ? Copy(tmp.Characters(font)) : null; _fallbacks = Copy(tmp.Fallbacks(font)); } + /// + /// Restores saved font state and rebuilds its definition; native failures propagate to the caller. + /// public void Restore(TmpRuntime tmp, Object font) { tmp.SetMode(font, _mode); @@ -280,6 +316,9 @@ public void Restore(TmpRuntime tmp, Object font) tmp.SetFallbacks(font, fallbacks); tmp.ReadDefinition(font); } + /// + /// Copies list entries into an array, preserving a null list as null. + /// private static object[] Copy(IList values) { if (values == null) return null; diff --git a/tests/ConfigurationTests.cs b/tests/ConfigurationTests.cs index e4f2871..89edc78 100644 --- a/tests/ConfigurationTests.cs +++ b/tests/ConfigurationTests.cs @@ -7,6 +7,9 @@ public sealed class ConfigurationTests { + /// + /// Verifies rule order, case-insensitive exact matches, exclusions, and the absence of matches. + /// [Fact] public void RulesUseFirstMatchAndExclusionsWin() { @@ -18,6 +21,9 @@ public void RulesUseFirstMatchAndExclusionsWin() Assert.Null(FontRules.Select("Body", new FontRule[0], new string[0])); } + /// + /// Verifies case-insensitive fragment matching and rejection of empty patterns. + /// [Fact] public void ContainsIsCaseInsensitiveAndEmptyNeverMatches() { @@ -25,6 +31,9 @@ public void ContainsIsCaseInsensitiveAndEmptyNeverMatches() Assert.False(new FontRule { Match = MatchKind.Contains, Pattern = "" }.Matches("BODY")); } + /// + /// Verifies Vietnamese coverage, custom Unicode scalars, deduplication, and rejection of unpaired surrogates. + /// [Fact] public void CharacterPresetIncludesVietnameseUpperLowerAndScalars() { @@ -40,6 +49,9 @@ public void CharacterPresetIncludesVietnameseUpperLowerAndScalars() Assert.Throws(() => FontRules.Characters("\uD800")); } + /// + /// Verifies custom-directory precedence, system-directory fallback, absolute paths, and empty input. + /// [Fact] public void RelativePathsPreferCustomFiles() { @@ -57,12 +69,18 @@ public void RelativePathsPreferCustomFiles() finally { Directory.Delete(directory, true); } } + /// + /// Verifies that list parsing trims entries and preserves their first occurrence. + /// [Fact] public void ListParsingTrimsAndDeduplicates() { Assert.Equal(new[] { "body", "menu" }, FontRules.Split(" body, ,menu,body ")); } + /// + /// Verifies that default settings contain no rules and no enabled primary font file. + /// [Fact] public void DefaultsNeverMutateFonts() { @@ -71,6 +89,9 @@ public void DefaultsNeverMutateFonts() Assert.Equal("", settings.Profiles[0].File); } + /// + /// Verifies that invalid IDs, atlas sizes, profile references, and empty rule patterns are rejected. + /// [Theory] [InlineData("[Fonts]\nProfiles = invalid.id\n")] [InlineData("[Fonts]\nProfiles = default\n[Font.default]\nAtlasSize = 513\n")] @@ -81,6 +102,9 @@ public void InvalidConfigurationIsRejected(string text) Assert.Throws(() => Load(text)); } + /// + /// Verifies that configured rule order and synchronization mode are retained. + /// [Fact] public void ConfigurationPreservesExplicitOrder() { @@ -89,6 +113,9 @@ public void ConfigurationPreservesExplicitOrder() Assert.Equal(PatchMode.Synchronize, settings.Rules[1].Mode); } + /// + /// Verifies that fallback files default to empty and preserve configured order after deduplication. + /// [Fact] public void ProfileFallbackFilesPreserveOrderAndDefaultToEmpty() { @@ -98,6 +125,9 @@ public void ProfileFallbackFilesPreserveOrderAndDefaultToEmpty() Assert.Equal(new[] { "Secondary.ttf", "Third.ttf" }, settings.Profiles[0].FallbackFiles); } + /// + /// Verifies that incomplete coverage persists until success and invalid missing-character reports are rejected. + /// [Fact] public void CoverageChainRequiresAllRemainingCharacters() { @@ -111,6 +141,9 @@ public void CoverageChainRequiresAllRemainingCharacters() Assert.Throws(() => FontRules.RemainingCharacters(requested, false, new uint[] { 999 })); } + /// + /// Loads settings from a temporary configuration file with automatic saving disabled, then deletes the file. + /// private static PluginSettings Load(string text) { string file = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".cfg"); diff --git a/tests/TmpRuntimeTests.cs b/tests/TmpRuntimeTests.cs index d50cc45..a11af98 100644 --- a/tests/TmpRuntimeTests.cs +++ b/tests/TmpRuntimeTests.cs @@ -7,6 +7,9 @@ public sealed class TmpRuntimeTests { + /// + /// Verifies reflected font creation, glyph population, atlas freezing, fallback assignment, and text refresh. + /// [Fact] public void AdapterValidatesAndInvokesCompatibleApi() { @@ -28,6 +31,9 @@ public void AdapterValidatesAndInvokesCompatibleApi() Assert.True(text.Refreshed); } + /// + /// Verifies that the adapter returns failed glyph population and its missing codes. + /// [Fact] public void AdapterReportsFailedGlyphPreflight() { @@ -36,6 +42,9 @@ public void AdapterReportsFailedGlyphPreflight() Assert.Equal(new uint[] { 0xFFFF }, missing); } + /// + /// Verifies that incompatible font or text types are rejected during adapter construction. + /// [Fact] public void MissingCapabilitiesAreRejectedBeforeMutation() { @@ -43,6 +52,9 @@ public void MissingCapabilitiesAreRejectedBeforeMutation() Assert.Throws(() => new TmpRuntime(typeof(TestFont), typeof(object), typeof(TestSource))); } + /// + /// Validates the configured external Unity and TMP assembly API in an isolated load context without rendering. + /// [GameApiFact] public void ExternalGameApiHasRequiredCapabilities() { @@ -75,9 +87,15 @@ public sealed class TestFont public TestPopulation atlasPopulationMode { get; set; } public object[] atlasTextures { get; } = new object[0]; public object material { get; } = new object(); + /// + /// Creates a fake font with the requested population mode and an optional trailing parameter for adapter validation. + /// public static TestFont CreateFontAsset(TestSource source, int size, int padding, TestRender render, int width, int height, TestPopulation mode, bool multiAtlas, bool optional = false) => new TestFont { atlasPopulationMode = mode }; + /// + /// Simulates population failure for U+FFFF; otherwise appends the requested codes and reports complete coverage. + /// public bool TryAddCharacters(uint[] characters, out uint[] missing, bool includeFeatures) { if (Array.IndexOf(characters, 0xFFFFu) >= 0) { missing = new uint[] { 0xFFFF }; return false; } @@ -85,18 +103,30 @@ public bool TryAddCharacters(uint[] characters, out uint[] missing, bool include missing = new uint[0]; return true; } + /// + /// Provides the no-op definition rebuild capability required by the adapter fixture. + /// public void ReadFontAssetDefinition() { } } public sealed class TestText { public TestFont font { get; set; } public bool Refreshed; + /// + /// Provides the no-op dirty notification capability required by the adapter fixture. + /// public void SetAllDirty() { } + /// + /// Records that the adapter requested a mesh refresh. + /// public void ForceMeshUpdate(bool ignoreActive, bool reparse) { Refreshed = true; } } public sealed class GameApiFactAttribute : FactAttribute { + /// + /// Skips external API validation when GAME_MANAGED_PATH is not configured. + /// public GameApiFactAttribute() { if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GAME_MANAGED_PATH"))) From 215896fa0115de769f23afb94fb107b0a80aa38c Mon Sep 17 00:00:00 2001 From: Zeldris <80939130+zeldrisho@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:16:10 +0700 Subject: [PATCH 5/5] fix: harden release metadata XML parsing --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 2 ++ .gitignore | 1 + docs/development.md | 14 +++++++++++--- scripts/package.py | 9 ++++++--- scripts/requirements.txt | 4 ++++ scripts/test_package.py | 28 ++++++++++++++++++++++++++++ 7 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 scripts/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c996c..a6580d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: - run: dotnet format whitespace UnityVietnameseFontSync.slnx --no-restore --verify-no-changes - run: dotnet build UnityVietnameseFontSync.slnx -c Release --no-restore --warnaserror - run: dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release --no-build --no-restore + - run: python3 -m pip install --require-hashes -r scripts/requirements.txt - run: python3 -m unittest discover -s scripts -p 'test_*.py' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1604052..d9ee3d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,8 @@ jobs: name: plugin path: artifacts/plugin + - run: python3 -m pip install --require-hashes -r scripts/requirements.txt + - name: Validate release metadata and package plugin env: RELEASE_TAG: ${{ github.ref_name }} diff --git a/.gitignore b/.gitignore index 9dfac39..f2c983d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ **/obj/ .vs/ .idea/ +.venv/ *.user *.suo Directory.Build.props diff --git a/docs/development.md b/docs/development.md index ed92fb2..e18e037 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,7 +11,15 @@ ## Commands -Run from the repository root: +Run from the repository root. Create and activate a Python virtual environment, then install the hash-verified packaging dependency: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --require-hashes -r scripts/requirements.txt +``` + +Activate the environment again in new shells before running Python tests or packaging. On Windows, use `.venv\Scripts\Activate.ps1` in PowerShell instead of `source`. ```bash dotnet restore UnityVietnameseFontSync.slnx --locked-mode @@ -61,7 +69,7 @@ A texture PNG alone is not a portable TMP font asset: glyph mappings, metrics, a ### Automated and in-game checks -Unit tests exercise configuration, matching, paths, Unicode scalars, adapter capability checks, optional parameters, and failed glyph-preflight reporting. Packaging tests check version/tag validation, changelog requirements, the exact four-file layout, documentation links, and deterministic archive contents. These are not native rendering tests. +Unit tests exercise configuration, matching, paths, Unicode scalars, adapter capability checks, optional parameters, and failed glyph-preflight reporting. Packaging tests check version/tag validation, changelog requirements, the exact four-file layout, documentation links, deterministic archive contents, and rejection of XML DTDs/entities before output is created. These are not native rendering tests. Optional local API-surface check (no game process or native font calls): @@ -85,7 +93,7 @@ Before claiming runtime support, manually test: Workflows use minimal permissions, timeouts, concurrency controls, and action SHA pins with stable version comments. Review dependency updates manually and retain lock files. Review the Unity compatibility baseline rather than blindly accepting engine upgrades. -CI restores in locked mode, checks formatting, builds, runs tests, checks packaging logic, and uploads only the plugin DLL. It does not need Steam or private reference assemblies. +CI restores in locked mode, checks formatting, builds, runs tests, checks packaging logic, and uploads only the plugin DLL. Both CI and release packaging install the pinned, hash-verified dependency from `scripts/requirements.txt`. The packager uses `defusedxml` to reject DTDs, entities, and external references when reading project metadata; this build-time dependency is not included in the plugin archive. It does not need Steam or private reference assemblies. To release: diff --git a/scripts/package.py b/scripts/package.py index dc09dcf..de22a62 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -6,9 +6,11 @@ import os from pathlib import Path import re -import xml.etree.ElementTree as ET import zipfile +from defusedxml import ElementTree as ET +from defusedxml.common import DefusedXmlException + ROOT = Path(__file__).resolve().parents[1] PROJECT = "UnityVietnameseFontSync" @@ -34,7 +36,8 @@ def package(root, tag, dll, output, repository_url=None): if not re.fullmatch(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", tag): raise ValueError("Use a stable SemVer tag such as v1.0.0.") version = tag[1:] - project = ET.parse(root / "src" / f"{PROJECT}.csproj") + project = ET.parse(root / "src" / f"{PROJECT}.csproj", + forbid_dtd=True, forbid_entities=True, forbid_external=True) if project.findtext(".//Version") != version: raise ValueError("Release tag must match the project Version.") notes = release_notes((root / "CHANGELOG.md").read_text(encoding="utf-8"), version) @@ -87,7 +90,7 @@ def main(): server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") repository_url = f"{server}/{repository}" if repository else None print(package(ROOT, args.tag, args.dll, args.output, repository_url)) - except ValueError as error: + except (ValueError, DefusedXmlException) as error: parser.exit(1, f"{error}\n") diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..3eaca04 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,4 @@ +# Build-time XML hardening only; never included in the plugin archive. +defusedxml==0.7.1 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 diff --git a/scripts/test_package.py b/scripts/test_package.py index 980d72a..461a123 100644 --- a/scripts/test_package.py +++ b/scripts/test_package.py @@ -3,6 +3,8 @@ import unittest import zipfile +from defusedxml.common import DTDForbidden + from package import package, release_notes @@ -16,6 +18,32 @@ def test_notes_require_dated_nonempty_release(self): with self.assertRaises(ValueError): release_notes(text, "1.0.0") + def test_rejects_unsafe_xml_before_creating_output(self): + documents = { + "dtd": '', + "internal_entities": ( + '' + ']>' + '&repeated;' + ), + "external_entity": ( + ']>' + '&external;' + ), + "external_dtd": '', + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "src").mkdir() + project = root / "src/UnityVietnameseFontSync.csproj" + output = root / "out" + for name, document in documents.items(): + with self.subTest(name=name): + project.write_text(document, encoding="utf-8") + with self.assertRaises(DTDForbidden): + package(root, "v1.0.0", root / "unused.dll", output) + self.assertFalse(output.exists()) + def test_exact_layout_version_and_reproducibility(self): """Verify archive contents, reproducibility, guide links, and rejection of invalid or mismatched tags.""" with tempfile.TemporaryDirectory() as directory: