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..a6580d9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +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.112" + 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 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 + 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..d9ee3d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +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 + + - run: python3 -m pip install --require-hashes -r scripts/requirements.txt + + - 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..f2c983d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +.vs/ +.idea/ +.venv/ +*.user +*.suo +Directory.Build.props +artifacts/ +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..149df59 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Agent Instructions + +## Toolchain + +- 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. + +## 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..2eb83f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# 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] + +## [1.0.0] - 2026-10-15 + +### 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. +- 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/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..7ec88f2 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# 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. + +## Compatibility + +- 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. + +## 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/`. The plugin DLL should be 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. + +## Worked configuration example + +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. + +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 = menu +CustomCharacters = + +[Font.menu] +File = MyVietnameseFont.ttf +FallbackFiles = CoverageFont.ttf +SamplingSize = 36 +Padding = 4 +AtlasSize = 2048 +MultiAtlas = true + +[Rules] +Order = menu-text +Exclude = Icons, Symbols + +[Rule.menu-text] +Profile = menu +Match = Exact +Pattern = MainMenu SDF +Mode = Synchronize +``` + +`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, 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 + +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] +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 | +| 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. + +## 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..40a99db --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,111 @@ +# 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 | +| 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 | +| 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 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.]` + +| 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. +- 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 + +```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. + +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 when the original font already has Vietnamese glyphs but their appearance is 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..e18e037 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,122 @@ +# 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.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. + +[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. 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 +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: + +- 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 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. + +### 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, 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): + +```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. 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: + +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/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..de22a62 --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,98 @@ +#!/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 zipfile + +from defusedxml import ElementTree as ET +from defusedxml.common import DefusedXmlException + +ROOT = Path(__file__).resolve().parents[1] +PROJECT = "UnityVietnameseFontSync" + + +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, + 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): + """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:] + 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) + 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): + """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})" + 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(): + """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) + 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, DefusedXmlException) as error: + parser.exit(1, f"{error}\n") + + +if __name__ == "__main__": + main() 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 new file mode 100644 index 0000000..461a123 --- /dev/null +++ b/scripts/test_package.py @@ -0,0 +1,85 @@ +from pathlib import Path +import tempfile +import unittest +import zipfile + +from defusedxml.common import DTDForbidden + +from package import package, release_notes + + +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.", + "## [1.0.0] - 2026-10-15\n"): + 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: + 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..0012126 --- /dev/null +++ b/src/FontRules.cs @@ -0,0 +1,118 @@ +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; + /// + /// Tests the asset name using the configured case-insensitive match kind. + /// + 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 + { + /// + /// 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(); + foreach (string part in (value ?? "").Split(',')) + if (part.Trim().Length > 0 && !result.Contains(part.Trim())) result.Add(part.Trim()); + 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) + if (name.IndexOf(exclusion, StringComparison.OrdinalIgnoreCase) >= 0) return null; + foreach (FontRule rule in rules) if (rule.Matches(name)) return rule; + 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; + if (Path.IsPathRooted(file)) return file; + string custom = Path.Combine(customDirectory, file); + if (File.Exists(custom)) return custom; + 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]; + 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; + } + + /// + /// 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(); + 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..c220bd3 --- /dev/null +++ b/src/PluginSettings.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using BepInEx.Configuration; + +namespace UnityVietnameseFontSync +{ + internal sealed class FontProfile + { + public string Id, File; + public string[] FallbackFiles; + 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; + + /// + /// 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; + 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, + 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, + 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); + } + } + + /// + /// 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) + 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..1afba88 --- /dev/null +++ b/src/TmpRuntime.cs @@ -0,0 +1,164 @@ +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; + + /// + /// Validates the reflected TMP API and enum values before allowing font creation or mutation. + /// + 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"); + } + + /// + /// 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()) + { + 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."); + } + + /// + /// 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(); + 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); + } + + /// + /// 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 }; + bool complete = (bool)_addCharacters.Invoke(font, arguments); + missing = (uint[])arguments[1]; + 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); + if (property == null || !property.CanRead || (writable && !property.CanWrite)) + throw new NotSupportedException(type.FullName + "." + name + " is unavailable."); + 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/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..b0b2bc0 --- /dev/null +++ b/src/UnityVietnameseFontSyncPlugin.cs @@ -0,0 +1,331 @@ +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, _firstFontScanLogged; + + /// + /// Loads configuration and initializes scanning, logging configuration failures without propagating them. + /// + private void Awake() + { + 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); } + } + + /// + /// 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; + 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(); + } + + /// + /// 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"); + if (string.IsNullOrWhiteSpace(profile.File)) return; + var files = new List { profile.File }; + files.AddRange(profile.FallbackFiles); + int start = _owned.Count; + try + { + 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 remaining) codes.Add("U+" + code.ToString("X4")); + throw new InvalidOperationException("Required glyphs could not be added: " + string.Join(", ", codes.ToArray())); + } + _profiles.Add(profile.Id, root); + 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; + } + } + + /// + /// 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; + value.hideFlags |= HideFlags.DontUnloadUnusedAsset; + _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; + _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)); + 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); + _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 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(); + 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); + 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); } + } + } + + /// + /// 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; + 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 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]); + } + } + + 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); + 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); + } + /// + /// Copies list entries into an array, preserving a null list as null. + /// + 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..89edc78 --- /dev/null +++ b/tests/ConfigurationTests.cs @@ -0,0 +1,154 @@ +using System; +using System.IO; +using System.Linq; +using BepInEx.Configuration; +using UnityVietnameseFontSync; +using Xunit; + +public sealed class ConfigurationTests +{ + /// + /// Verifies rule order, case-insensitive exact matches, exclusions, and the absence of matches. + /// + [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])); + } + + /// + /// Verifies case-insensitive fragment matching and rejection of empty patterns. + /// + [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")); + } + + /// + /// Verifies Vietnamese coverage, custom Unicode scalars, deduplication, and rejection of unpaired surrogates. + /// + [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")); + } + + /// + /// Verifies custom-directory precedence, system-directory fallback, absolute paths, and empty input. + /// + [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); } + } + + /// + /// 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() + { + var settings = Load(""); + Assert.Empty(settings.Rules); + 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")] + [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)); + } + + /// + /// Verifies that configured rule order and synchronization mode are retained. + /// + [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); + } + + /// + /// Verifies that fallback files default to empty and preserve configured order after deduplication. + /// + [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); + } + + /// + /// Verifies that incomplete coverage persists until success and invalid missing-character reports are rejected. + /// + [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 })); + } + + /// + /// 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"); + 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..a11af98 --- /dev/null +++ b/tests/TmpRuntimeTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.Loader; +using UnityVietnameseFontSync; +using Xunit; + +public sealed class TmpRuntimeTests +{ + /// + /// Verifies reflected font creation, glyph population, atlas freezing, fallback assignment, and text refresh. + /// + [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); + } + + /// + /// Verifies that the adapter returns failed glyph population and its missing codes. + /// + [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); + } + + /// + /// Verifies that incompatible font or text types are rejected during adapter construction. + /// + [Fact] + public void MissingCapabilitiesAreRejectedBeforeMutation() + { + Assert.Throws(() => new TmpRuntime(typeof(object), typeof(TestText), typeof(TestSource))); + 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() + { + 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(); + /// + /// 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; } + characterTable.AddRange(characters); + 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"))) + 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