From ec44854e43b55fa5cd7839b1b7a16776df0dc382 Mon Sep 17 00:00:00 2001
From: Zeldris <80939130+zeldrisho@users.noreply.github.com>
Date: Thu, 15 Oct 2026 01:11:14 +0700
Subject: [PATCH 1/5] feat: add configurable Unity Vietnamese font sync plugin
---
.editorconfig | 19 ++
.github/workflows/ci.yml | 41 +++
.github/workflows/release.yml | 48 +++
.gitignore | 10 +
AGENTS.md | 37 ++
CHANGELOG.md | 16 +
LICENSE | 21 ++
NuGet.Config | 8 +
README.md | 106 ++++++
UnityVietnameseFontSync.slnx | 8 +
docs/configuration.md | 94 ++++++
docs/development.md | 114 +++++++
docs/plan.md | 22 ++
global.json | 7 +
scripts/package.py | 91 +++++
scripts/test_package.py | 55 +++
src/FontRules.cs | 82 +++++
src/PluginSettings.cs | 72 ++++
src/TmpRuntime.cs | 110 ++++++
src/UnityVietnameseFontSync.csproj | 21 ++
src/UnityVietnameseFontSyncPlugin.cs | 242 ++++++++++++++
src/packages.lock.json | 371 +++++++++++++++++++++
tests/ConfigurationTests.cs | 99 ++++++
tests/TmpRuntimeTests.cs | 106 ++++++
tests/UnityVietnameseFontSync.Tests.csproj | 16 +
tests/packages.lock.json | 144 ++++++++
26 files changed, 1960 insertions(+)
create mode 100644 .editorconfig
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 .gitignore
create mode 100644 AGENTS.md
create mode 100644 CHANGELOG.md
create mode 100644 LICENSE
create mode 100644 NuGet.Config
create mode 100644 README.md
create mode 100644 UnityVietnameseFontSync.slnx
create mode 100644 docs/configuration.md
create mode 100644 docs/development.md
create mode 100644 docs/plan.md
create mode 100644 global.json
create mode 100644 scripts/package.py
create mode 100644 scripts/test_package.py
create mode 100644 src/FontRules.cs
create mode 100644 src/PluginSettings.cs
create mode 100644 src/TmpRuntime.cs
create mode 100644 src/UnityVietnameseFontSync.csproj
create mode 100644 src/UnityVietnameseFontSyncPlugin.cs
create mode 100644 src/packages.lock.json
create mode 100644 tests/ConfigurationTests.cs
create mode 100644 tests/TmpRuntimeTests.cs
create mode 100644 tests/UnityVietnameseFontSync.Tests.csproj
create mode 100644 tests/packages.lock.json
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..200f1b4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{cs,csproj,props,config,slnx}]
+indent_style = space
+indent_size = 4
+
+[*.{yml,yaml,json}]
+indent_style = space
+indent_size = 2
+
+[**/obj/**]
+generated_code = true
+end_of_line = unset
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c17af13
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,41 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+ workflow_call:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: "10.0.402"
+ cache: true
+ cache-dependency-path: "**/packages.lock.json"
+
+ - run: dotnet restore UnityVietnameseFontSync.slnx --locked-mode
+ - run: dotnet format whitespace UnityVietnameseFontSync.slnx --no-restore --verify-no-changes
+ - run: dotnet build UnityVietnameseFontSync.slnx -c Release --no-restore --warnaserror
+ - run: dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release --no-build --no-restore
+ - run: python3 -m unittest discover -s scripts -p 'test_*.py'
+
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: plugin
+ path: src/bin/Release/netstandard2.1/UnityVietnameseFontSync.dll
+ if-no-files-found: error
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..1604052
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,48 @@
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ check:
+ uses: ./.github/workflows/ci.yml
+
+ publish:
+ needs: check
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: plugin
+ path: artifacts/plugin
+
+ - name: Validate release metadata and package plugin
+ env:
+ RELEASE_TAG: ${{ github.ref_name }}
+ run: python3 scripts/package.py --tag "$RELEASE_TAG" --dll artifacts/plugin/UnityVietnameseFontSync.dll
+
+ - name: Publish release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ run: >-
+ gh release create "$RELEASE_TAG"
+ artifacts/release/*.zip
+ --verify-tag --title "$RELEASE_TAG"
+ --notes-file artifacts/release/notes.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9dfac39
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+**/bin/
+**/obj/
+.vs/
+.idea/
+*.user
+*.suo
+Directory.Build.props
+artifacts/
+__pycache__/
+*.pyc
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..28c50a1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,37 @@
+# Agent Instructions
+
+## Toolchain
+
+- Use stable .NET 10 via `global.json` and Python 3 for packaging checks.
+- Restore with `dotnet restore UnityVietnameseFontSync.slnx --locked-mode`.
+- Stop and report missing development tools or dependencies.
+
+## Commands
+
+| Task | Command |
+|---|---|
+| Configuration/adapter tests | `dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release` |
+| Release packaging tests | `python3 -m unittest discover -s scripts -p 'test_*.py'` |
+| Check formatting | `dotnet format whitespace UnityVietnameseFontSync.slnx --no-restore --verify-no-changes` |
+| Build | `dotnet build UnityVietnameseFontSync.slnx -c Release --no-restore --warnaserror` |
+
+## Key Conventions
+
+- Edit plugin identity/version in `src/UnityVietnameseFontSync.csproj`; do not edit generated `MyPluginInfo` constants.
+- Keep game paths/mappings, game DLLs, and proprietary fonts out of committed runtime/build configuration.
+- Keep runtime compatibility checks capability-based; do not add game-name/version heuristics.
+- Keep destructive synchronization opt-in and unmatched fonts unchanged.
+- Update lock files with dependency changes; review Unity baseline upgrades for compatibility.
+- Record user-visible changes under `Unreleased` in `CHANGELOG.md`.
+- Do not deploy, publish, or commit unless requested.
+
+## External References
+
+| Need | File |
+|---|---|
+| User setup | `README.md` |
+| Configuration contract | `docs/configuration.md` |
+| Runtime design, build, testing, release | `docs/development.md` |
+| Change history | `CHANGELOG.md` |
+| CI | `.github/workflows/ci.yml` |
+| Published releases | `.github/workflows/release.yml` |
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..43dc4c3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
+# Changelog
+
+All notable user-facing changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
+and this project uses [Semantic Versioning](https://semver.org/).
+
+## [Unreleased]
+
+### Added
+
+- Vietnamese glyph fallback for supported Unity Mono games using TextMeshPro.
+- Optional font synchronization to keep accented and unaccented letters visually consistent.
+- Multiple font profiles, ordered matching rules, and exclusions for fonts such as icons and symbols.
+- Configurable font files, atlas settings, and support for late-loaded game fonts.
+- Warnings for missing font files, unsupported TMP APIs, and characters the selected font cannot provide.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..89ae11e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Zeldris
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NuGet.Config b/NuGet.Config
new file mode 100644
index 0000000..1280bf4
--- /dev/null
+++ b/NuGet.Config
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d8b562f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,106 @@
+# Unity Vietnamese Font Sync
+
+A configurable **BepInEx 5 plugin for Unity Mono games using TextMeshPro**. Add missing Vietnamese glyphs or use a consistent replacement font for selected font groups.
+
+This plugin does not translate text. It can work alongside XUnity.AutoTranslator or an existing Vietnamese translation.
+
+## Status
+
+**Development build; in-game validation is pending.** Automated tests and API-surface checks against two different game assembly sets pass. They do not verify native font generation or rendering.
+
+- Initial runtime target: Windows, BepInEx 5, Unity Mono, compatible TextMeshPro.
+- No game-specific font mappings or paths are built in.
+- BepInEx 6, IL2CPP, legacy Unity UI, NGUI, and UI Toolkit are not supported.
+- TMP APIs are discovered at runtime; missing required capabilities disable font changes with a log message. This is not a guarantee of compatibility with every Unity/TMP version.
+
+## Features
+
+- Named font profiles and explicitly ordered matching rules.
+- **Fallback:** retain original glyphs and append a Vietnamese-capable font.
+- **Synchronize:** opt-in replacement of a matched font group's glyphs.
+- Preserve existing fallback chains; leave unmatched fonts unchanged.
+- Validate required glyph coverage before applying a profile.
+- Discover late-loaded fonts with configurable scanning, retry delays, and diagnostics.
+- Load local font files without downloading fonts or using a translation service.
+
+## Installation
+
+1. Install [BepInEx 5 for Unity Mono](https://docs.bepinex.dev/articles/user_guide/installation/index.html).
+2. Extract the release archive into `/BepInEx/`, or [build from source](docs/development.md) and place the DLL at:
+ ```text
+ /BepInEx/plugins/zeldrisho.UnityVietnameseFontSync/UnityVietnameseFontSync.dll
+ ```
+3. Run the game once with the plugin installed to generate:
+ ```text
+ /BepInEx/config/zeldrisho.UnityVietnameseFontSync.cfg
+ ```
+4. Close the game, configure a font file and matching rules, then start it again.
+
+There is no need for a separate BepInEx-only run before installing this plugin. If you previously used a UnityExplorer font script, restart with that script disabled and do not run it alongside the plugin.
+
+The initial configuration makes **no font changes**. No game assemblies or proprietary font files are bundled.
+
+## Quick configuration
+
+Enable `Diagnostics = true` to log TMP font asset names, or optionally inspect them with UnityExplorer. Match font asset names, not component names.
+
+Example for a fictional `BodyText SDF` asset:
+
+```ini
+[Fonts]
+Profiles = body
+
+[Font.body]
+File = MyVietnameseFont.ttf
+
+[Rules]
+Order = body-text
+Exclude = Icons, Symbols
+
+[Rule.body-text]
+Profile = body
+Match = Exact
+Pattern = BodyText SDF
+Mode = Fallback
+```
+
+Place the font in the plugin's `Fonts/` directory, select a filename from Windows Fonts, or specify an absolute path. Use a font you are licensed to use that covers all required characters.
+
+See [configuration](docs/configuration.md) for multiple profiles, exclusions, synchronization, and atlas settings.
+
+**Synchronization clears the matched font's character table in memory.** Use narrow rules and check symbols, untranslated text, and layout. Restart to recover. Matching outlines, shadows, spacing, and line breaks is not guaranteed.
+
+## With XUnity.AutoTranslator
+
+When this plugin owns TMP fonts, start with XUnity's competing font settings empty:
+
+```ini
+[Behaviour]
+OverrideFontTextMeshPro=
+FallbackFontTextMeshPro=
+```
+
+Keep translation enabled. This plugin neither edits XUnity configuration nor rewrites translated strings. Avoid `RemoveAllDiacritics` in translation postprocessing.
+
+## Troubleshooting
+
+| Symptom | Check |
+|---|---|
+| Nothing changes | `Rules.Order`, exact asset names, `Enabled`, and profile `File`; rules are empty by default |
+| Profile disabled | Log warnings for missing files, API failures, or missing `U+...` characters; try another font or larger atlas |
+| Accents look wrong | Fallback cannot replace an existing incorrect glyph; consider narrowly targeted synchronization |
+| Mixed styles | Fallback preserves available original glyphs; synchronization changes the whole matched font group |
+| Icons/other languages disappear | Avoid broad synchronization and verify preserved fallback coverage |
+| Combining marks fail | Add marks to `CustomCharacters`; test the source font and TMP shaping behavior |
+
+To disable, set `Enabled = false` and restart. To uninstall, close the game and remove the plugin DLL.
+
+## Development
+
+See [development](docs/development.md) for build/test commands, runtime design, and release steps. Notable changes are recorded in [CHANGELOG.md](CHANGELOG.md).
+
+CI checks formatting, locked restore, compilation, tests, and packaging logic. A matching version tag publishes the release ZIP after those checks pass.
+
+## License
+
+[MIT](LICENSE). The license covers this plugin and its documentation, not third-party fonts or game assemblies. Do not redistribute proprietary system fonts or game assets.
diff --git a/UnityVietnameseFontSync.slnx b/UnityVietnameseFontSync.slnx
new file mode 100644
index 0000000..84acce6
--- /dev/null
+++ b/UnityVietnameseFontSync.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..d2b59f9
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,94 @@
+# Configuration
+
+File: `BepInEx/config/zeldrisho.UnityVietnameseFontSync.cfg`. Edit while the game is closed and restart after changes. Hot reload is not supported.
+
+## Global settings
+
+| Section | Key | Default | Behavior |
+|---|---|---|---|
+| General | Enabled | true | Enable plugin |
+| General | Diagnostics | false | Log each live TMP font asset name once |
+| General | ScanInterval | 2 | Discovery interval in seconds, 0–300; zero disables periodic scans, not startup/scene scans |
+| General | RetryInterval | 30 | Delay before retrying a failed font mutation, 1–600 seconds |
+| Fonts | Profiles | default | Comma-separated profile IDs |
+| Fonts | CustomCharacters | empty | Characters added to the ASCII/Vietnamese preset for every profile |
+| Rules | Order | empty | Comma-separated rule IDs in first-match order |
+| Rules | Exclude | empty | Case-insensitive asset-name fragments excluded before all rules |
+
+IDs contain letters, digits, underscores, or hyphens. Lists trim whitespace and remove exact duplicates. IDs/profile references are case-sensitive; asset matching is case-insensitive. Unlisted sections are inactive.
+
+The preset covers printable ASCII and uppercase/lowercase precomposed Vietnamese. Add curly quotes, ellipsis, combining marks, or other required characters explicitly. Game text is not normalized or rewritten. Invalid surrogate sequences are rejected.
+
+## Profiles: `[Font.]`
+
+| Key | Default | Allowed values |
+|---|---|---|
+| File | empty | Font filename, relative path, or absolute path; empty disables profile |
+| SamplingSize | 36 | 8–128 |
+| Padding | 4 | 1–32 |
+| AtlasSize | 2048 | 256, 512, 1024, 2048, 4096 |
+| MultiAtlas | true | Allow additional dynamic atlas pages |
+
+Relative paths search `/Fonts/` first, then the OS Fonts directory. Absolute paths are used directly. Paths must be valid on the machine running the game; a Linux `/mnt/c/...` path is not a Windows runtime path.
+
+Profiles must successfully add the entire preset plus custom characters. Missing glyphs or atlas population failures disable the profile and log missing Unicode values where TMP supplies them. Mapped game fonts are left alone. Files are loaded once at startup; restart to retry after fixing a file.
+
+## Rules: `[Rule.]`
+
+| Key | Default | Behavior |
+|---|---|---|
+| Profile | default | An ID in `Fonts.Profiles` |
+| Match | Exact | `Exact`, `Contains`, or `All` |
+| Pattern | empty | Required for Exact/Contains; ignored for All |
+| Mode | Fallback | `Fallback` or `Synchronize` |
+
+- First match wins. An unavailable target profile does not fall through to another rule.
+- `All` explicitly matches every non-excluded game font. `*` is not a wildcard in Exact/Contains.
+- Fallback appends the profile after existing fallbacks; original glyphs remain unchanged.
+- Synchronize inserts the profile first, freezes the original atlas, clears its whole character table, and rebuilds lookup data. Existing fallbacks remain afterward.
+- Synchronize can break symbols and non-Latin text absent from the replacement and preserved fallbacks. There is no automatic icon detection or serif/sans classification.
+
+## Multiple-profile example
+
+```ini
+[Fonts]
+Profiles = body, menu
+CustomCharacters = …“”
+
+[Font.body]
+File = BodyFont.ttf
+
+[Font.menu]
+File = MenuFont.ttf
+SamplingSize = 40
+
+[Rules]
+Order = body, menu
+Exclude = Icons, Symbols
+
+[Rule.body]
+Profile = body
+Match = Contains
+Pattern = BodyText
+Mode = Fallback
+
+[Rule.menu]
+Profile = menu
+Match = Exact
+Pattern = MenuText SDF
+Mode = Synchronize
+```
+
+Names are illustrative, not built-in game mappings. Start with exact Fallback rules; inspect coverage and appearance before enabling Synchronize.
+
+## Replacing a manual UnityExplorer script
+
+Manual C# font changes affect the current process; they are not plugin configuration. Disable any saved/startup font script, close the game completely, and start with this plugin to generate its configuration. Recreate the intended font-to-asset mappings as profiles and rules; there are no existing plugin settings to migrate.
+
+For example, the previously tested two-style setup maps `MortyOld` asset-name fragments to `palab.ttf` (Palatino Linotype Bold) and `Feruka` fragments to `bahnschrift.ttf` (Bahnschrift), using Synchronize for both. Those are optional user mappings, not built-in defaults or recommendations for every game. Use separate rules for each group instead of an `else` rule replacing all remaining fonts. Verify the files exist and pass glyph preflight on the target machine.
+
+Use Fallback when only missing-character coverage is needed. Use Synchronize when a matched font group should render both accented and ordinary letters through the selected profile, including games whose existing fallback already provides Vietnamese but looks visually inconsistent.
+
+## Recovery
+
+Invalid IDs, atlas sizes, unknown profile references, and empty matching patterns disable initialization. BepInEx may use defaults for malformed typed values; check logs and generated comments. No disk game assets are modified. Restarting without the plugin or manual font scripts returns to the game's original loaded assets.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..b7fe67d
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,114 @@
+# Development
+
+## Toolchain and layout
+
+- Stable .NET 10 SDK, Git, and Python 3 for release packaging/tests.
+- `global.json` accepts installed stable .NET 10 SDK feature bands; CI pins SDK 10.0.402, the latest stable .NET 10 SDK reported when configured. Local checks used 10.0.112.
+- `src/`: the plugin project; `tests/`: Unity-independent tests; `scripts/`: release packaging.
+- Public compile-time packages and lock files make clean builds independent of game installations. Never commit or distribute game assemblies or proprietary fonts.
+
+[Microsoft's .NET documentation](https://learn.microsoft.com/en-us/dotnet/) covers the [CLI](https://learn.microsoft.com/en-us/dotnet/core/tools/), [formatting](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format), and [GitHub Actions](https://learn.microsoft.com/en-us/dotnet/devops/github-actions-overview).
+
+## Commands
+
+Run from the repository root:
+
+```bash
+dotnet restore UnityVietnameseFontSync.slnx --locked-mode
+dotnet format whitespace UnityVietnameseFontSync.slnx --no-restore --verify-no-changes
+dotnet build UnityVietnameseFontSync.slnx -c Release --no-restore --warnaserror
+dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release --no-build --no-restore
+python3 -m unittest discover -s scripts -p 'test_*.py'
+```
+
+To apply formatting, omit `--verify-no-changes`. Output: `src/bin/Release/netstandard2.1/UnityVietnameseFontSync.dll`. Deploy only that DLL, manually, to the game's plugin directory. Build/test commands never deploy.
+
+The project originated from official [BepInEx.Templates](https://github.com/BepInEx/BepInEx.Templates). Stable 1.4.0 generation failed under SDK 10.0.112; documented template 2.0.0-be.4 worked. That scaffolding exception does not require a bleeding-edge runtime. Restore/build does not require templates to remain installed.
+
+Dependencies use exact versions plus lock files. BepInEx packages use the latest stable versions available in the configured feed when checked. UnityEngine.Modules 2022.3.0 is an intentional compile-only compatibility baseline, not a required game version; blindly selecting the newest Unity package could introduce newer runtime API dependencies. No Unity/TMP/game DLL ships with the plugin. `Version`, display name, and GUID live in `src/UnityVietnameseFontSync.csproj`; template tooling generates constants for the plugin attribute. The initial stable version is `1.0.0`, matching the .NET SDK default and using three-part SemVer. The SDK derives the assembly/file versions (`1.0.0.0` initially); no separate version constants are maintained.
+
+## Runtime design
+
+| File | Responsibility |
+|---|---|
+| `src/FontRules.cs` | Matching, list parsing, paths, Unicode preset |
+| `src/PluginSettings.cs` | One BepInEx config; profiles, rules, validation |
+| `src/TmpRuntime.cs` | Discover and validate TMP capabilities without a TMP compile-time reference |
+| `src/UnityVietnameseFontSyncPlugin.cs` | Generation, discovery, mutation, rollback, refresh |
+
+At startup the adapter probes loaded TMP types, required properties, character APIs, and a compatible multi-argument Font-to-SDF creation overload. Unsupported capabilities prevent mutation; scene/periodic scans retry discovery so late-loaded TMP assemblies can be handled, with warnings logged only once. Optional trailing creation parameters use their declared defaults. Version strings are not used to choose behavior. Native runtime failures can still occur and need in-game testing.
+
+Profiles are preflighted independently. Startup, scene changes, and periodic scans select the first matching rule after exclusions. Live object references track successful patches; destroyed entries are pruned. Per-font failures attempt snapshot rollback and use the configured retry delay. Generated profiles have no links back to game fonts, avoiding newly introduced fallback cycles.
+
+Snapshots preserve original fallbacks/atlas mode and, for Synchronize, characters. Only text whose primary font directly changed is refreshed; text using that asset indirectly through fallbacks may need normal game refresh. Strings are never temporarily cleared. Original glyph tables and atlas textures are not cleared.
+
+Generated assets/pages are pinned. Best-effort restoration runs at plugin destruction, but used generated materials remain until process exit because cached submeshes/other plugins may reference them. Hot unloading and live configuration reload are not supported. Restoration may conflict with another plugin modifying the same font; restart is the recovery path.
+
+## Validation
+
+### Manual baseline and limits
+
+The working starting point was C# run inside UnityExplorer, not an installed version of this plugin or an existing plugin configuration:
+
+- In Bō (Unity 2022.3.0f1, Mono), user-reported menu/settings success used Palatino Linotype Bold (`palab.ttf`) for `MortyOld` and Bahnschrift (`bahnschrift.ttf`) for `Feruka`, with sampling size 36, padding 4, 2048-square dynamic replacement atlases, and ASCII/Vietnamese preloading.
+- The successful synchronization approach froze matched original assets to Static, cleared their character tables, rebuilt lookup data, and used replacement fallbacks. The plugin implements that approach through explicit rules while preserving original fallback chains.
+- Missing glyphs and visually incorrect existing glyphs are different problems. Removing only lookup entries did not resolve the manual failure; a dynamic original font could add glyphs again. A fallback alone also mixed accented/unaccented styles.
+- A tested Trebuchet replacement produced missing-character boxes. That observation is not a universal coverage claim about every font release; preflight each actual file. Never assume an atlas size guarantees sufficient capacity.
+- Feather's Edge (Unity 6000.0.49f1, Mono) already logged successful XUnity fallback creation; style consistency remains a separate use case. No complete replacement mapping or new-plugin rendering result has been verified there.
+- TMP package versions cannot be established from those logs alone. Original metrics/material behavior, all font names, gameplay screens beyond the reported menus, long-term XUnity interactions, and performance remain unverified. Do not carry forward speculative explanations or claims of perfect shader preservation.
+
+A texture PNG alone is not a portable TMP font asset: glyph mappings, metrics, and other font data are also required. Runtime-generated fonts are not exported by this plugin. Restarting clears manual in-memory changes; disable startup scripts before testing the plugin independently.
+
+### Automated and in-game checks
+
+Unit tests exercise configuration, matching, paths, Unicode scalars, adapter capability checks, optional parameters, and failed glyph-preflight reporting. Packaging tests check version/tag validation, changelog requirements, the exact four-file layout, documentation links, and deterministic archive contents. These are not native rendering tests.
+
+Optional local API-surface check (no game process or native font calls):
+
+```bash
+GAME_MANAGED_PATH="/path/to/Game_Data/Managed" \
+ dotnet test tests/UnityVietnameseFontSync.Tests.csproj -c Release --filter ExternalGameApi
+```
+
+The optional check loads the selected assembly set in an isolated context and is explicitly skipped without the environment variable. It passed against both available Unity 2022-era and Unity 6-era game reference sets. No game names or paths are encoded in tests or runtime decisions.
+
+Before claiming runtime support, manually test:
+
+- Exact Fallback and Synchronize rules, multiple profiles, missing files/glyphs, and atlas exhaustion.
+- All Vietnamese cases/tones, combining marks, punctuation, icons, original-language fallbacks, rich text, and weights.
+- Outlines/shadows, spacing, wrapping, clipping, and small/large sizes.
+- Startup, scenes, async loads, inactive text activation, retries, and long-session atlas growth.
+- XUnity coexistence with its competing TMP font settings empty.
+- Discovery/refresh timings and memory in multiple games. No rendering/performance claims are verified yet.
+
+## CI and release
+
+Workflows use minimal permissions, timeouts, concurrency controls, and action SHA pins with stable version comments. Review dependency updates manually and retain lock files. Review the Unity compatibility baseline rather than blindly accepting engine upgrades.
+
+CI restores in locked mode, checks formatting, builds, runs tests, checks packaging logic, and uploads only the plugin DLL. It does not need Steam or private reference assemblies.
+
+To release:
+
+1. Finish manual validation and review the MIT license and any third-party redistribution rights.
+2. Set the project `Version`; move relevant user-facing `Unreleased` changes to `## [X.Y.Z] - YYYY-MM-DD` in `CHANGELOG.md` and retain `Unreleased` above it. Keep developer instructions, CI changes, and test implementation details out of the changelog.
+3. Validate packaging locally, commit, and push a matching stable `vX.Y.Z` tag only when ready to publish.
+4. The release workflow reruns CI, validates tag/version/changelog consistency, creates the allowlisted ZIP, and publishes a normal GitHub release automatically. **Tagging is the publication trigger; there is no draft/manual-publish step.**
+
+Local packaging after adding the dated release entry:
+
+```bash
+python3 scripts/package.py --tag vX.Y.Z --dll src/bin/Release/netstandard2.1/UnityVietnameseFontSync.dll
+```
+
+The initial archive is `UnityVietnameseFontSync-1.0.0.zip`; future archives retain the `UnityVietnameseFontSync-X.Y.Z.zip` pattern. It contains exactly:
+
+```text
+plugins/zeldrisho.UnityVietnameseFontSync/UnityVietnameseFontSync.dll
+plugins/zeldrisho.UnityVietnameseFontSync/README.md
+plugins/zeldrisho.UnityVietnameseFontSync/CHANGELOG.md
+plugins/zeldrisho.UnityVietnameseFontSync/LICENSE
+```
+
+Extract into `/BepInEx/`, not the game root. The install directory is derived from the project GUID; the DLL name remains the assembly name. Guides stay in the repository. During GitHub packaging, README guide links point to that repository's release tag; local packages without GitHub metadata use plain-text documentation references instead of broken local links.
+
+The ZIP is the only attached release asset. The generated `notes.md` becomes the GitHub release body, not an archive entry. Separate checksum files are optional and omitted for this small distribution. No fonts, game/BepInEx/Unity DLLs, generated config, debug symbols, or internal planning notes are included. Users supply fonts and generate per-game configuration on first launch. Comparison links can be added once the repository remote and first release exist.
diff --git a/docs/plan.md b/docs/plan.md
new file mode 100644
index 0000000..0a8a18b
--- /dev/null
+++ b/docs/plan.md
@@ -0,0 +1,22 @@
+# Remaining work
+
+## Runtime validation
+
+- [ ] Run the new runtime-adapter plugin in multiple Unity Mono games; record actual BepInEx, Unity, and TMP versions.
+- [ ] Verify startup and late TMP assembly loading, missing/stripped capabilities, and native font creation failures.
+- [ ] Validate Fallback/Synchronize independently, failed profile safety, symbols, combining marks, original-language fallbacks, rich text, and weights.
+- [ ] Compare outlines, shadows, spacing, wrapping, and clipping at different sizes.
+- [ ] Test scenes, late assets, inactive text activation, retries, and long-session multi-atlas memory behavior.
+- [ ] Verify indirect fallback users refresh; extend refresh propagation if required.
+- [ ] Verify XUnity coexistence and measure discovery/refresh costs.
+- [ ] Add Unity integration coverage for mutation rollback and cleanup; API-surface and fake-adapter tests do not verify native rendering.
+
+## Release readiness
+
+- [ ] Confirm redistribution rights for any future third-party resources; plugin code and documentation now use MIT.
+- [ ] Run the workflows on GitHub after a repository remote is configured; local checks cannot verify hosted artifact/release permissions.
+- [ ] Validate the release archive and runtime behavior before pushing a version tag; the workflow publishes a normal release automatically.
+- [ ] Add changelog tag/comparison links once the remote and first release exist.
+- [ ] Recheck stable BepInEx.Templates generation with newer SDKs; the existing project does not need template regeneration.
+
+The two permanent guides are [configuration.md](configuration.md) and [development.md](development.md). Architecture notes are consolidated into development rather than maintained as a third guide.
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..d46d21e
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature",
+ "allowPrerelease": false
+ }
+}
diff --git a/scripts/package.py b/scripts/package.py
new file mode 100644
index 0000000..fed0db6
--- /dev/null
+++ b/scripts/package.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""Validate a stable release and package only plugin-owned files."""
+
+import argparse
+import datetime
+import os
+from pathlib import Path
+import re
+import xml.etree.ElementTree as ET
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+PROJECT = "UnityVietnameseFontSync"
+
+
+def release_notes(changelog, version):
+ match = re.search(
+ rf"^## \[{re.escape(version)}\] - (\d{{4}}-\d{{2}}-\d{{2}})\s*\n(.*?)(?=^## |\Z)",
+ changelog,
+ re.MULTILINE | re.DOTALL,
+ )
+ if not match:
+ raise ValueError(f"Add a dated [{version}] entry to CHANGELOG.md before tagging.")
+ datetime.date.fromisoformat(match.group(1))
+ notes = match.group(2).strip()
+ if not notes or not re.search(r"^- \S", notes, re.MULTILINE):
+ raise ValueError("Release notes must contain a human-readable change entry.")
+ return notes + "\n"
+
+
+def package(root, tag, dll, output, repository_url=None):
+ if not re.fullmatch(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", tag):
+ raise ValueError("Use a stable SemVer tag such as v1.0.0.")
+ version = tag[1:]
+ project = ET.parse(root / "src" / f"{PROJECT}.csproj")
+ if project.findtext(".//Version") != version:
+ raise ValueError("Release tag must match the project Version.")
+ notes = release_notes((root / "CHANGELOG.md").read_text(encoding="utf-8"), version)
+ plugin_id = project.findtext(".//BepInExPluginGuid")
+ if not plugin_id or not re.fullmatch(r"[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*", plugin_id):
+ raise ValueError("Project BepInExPluginGuid must be a safe directory name.")
+ prefix = f"plugins/{plugin_id}"
+ # An explicit allowlist prevents game DLLs, fonts, local notes, or build dependencies leaking.
+ files = {
+ f"{prefix}/{PROJECT}.dll": dll,
+ f"{prefix}/README.md": root / "README.md",
+ f"{prefix}/CHANGELOG.md": root / "CHANGELOG.md",
+ f"{prefix}/LICENSE": root / "LICENSE",
+ }
+ for source in files.values():
+ if not source.is_file():
+ raise ValueError(f"Missing package input: {source}")
+ output.mkdir(parents=True, exist_ok=True)
+ archive = output / f"{PROJECT}-{version}.zip"
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
+ for name, source in files.items():
+ entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
+ entry.compress_type = zipfile.ZIP_DEFLATED
+ entry.external_attr = 0o100644 << 16
+ content = source.read_bytes()
+ if source == root / "README.md":
+ # Guides stay in the repository, not the install archive. Avoid broken local links.
+ def documentation_link(match):
+ label, path = match.groups()
+ if repository_url:
+ return f"[{label}]({repository_url.rstrip('/')}/blob/{tag}/{path})"
+ return f"{label} (see the project repository)"
+ content = re.sub(r"\[([^\]]+)\]\((docs/[^)]+)\)", documentation_link,
+ content.decode("utf-8")).encode("utf-8")
+ bundle.writestr(entry, content)
+ (output / "notes.md").write_text(notes, encoding="utf-8")
+ return archive
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--tag", required=True)
+ parser.add_argument("--dll", type=Path, required=True)
+ parser.add_argument("--output", type=Path, default=ROOT / "artifacts/release")
+ args = parser.parse_args()
+ try:
+ repository = os.environ.get("GITHUB_REPOSITORY")
+ server = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
+ repository_url = f"{server}/{repository}" if repository else None
+ print(package(ROOT, args.tag, args.dll, args.output, repository_url))
+ except ValueError as error:
+ parser.exit(1, f"{error}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_package.py b/scripts/test_package.py
new file mode 100644
index 0000000..9fb222b
--- /dev/null
+++ b/scripts/test_package.py
@@ -0,0 +1,55 @@
+from pathlib import Path
+import tempfile
+import unittest
+import zipfile
+
+from package import package, release_notes
+
+
+class PackageTests(unittest.TestCase):
+ def test_notes_require_dated_nonempty_release(self):
+ self.assertEqual("### Added\n- Example.\n", release_notes(
+ "## [1.0.0] - 2026-10-15\n\n### Added\n- Example.\n\n## [0.9.0] - 2026-09-01\n", "1.0.0"))
+ for text in ("## [Unreleased]\n- Example.", "## [1.0.0] - 2026-99-99\n- Example.",
+ "## [1.0.0] - 2026-10-15\n"):
+ with self.assertRaises(ValueError):
+ release_notes(text, "1.0.0")
+
+ def test_exact_layout_version_and_reproducibility(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / "src").mkdir()
+ (root / "docs").mkdir()
+ (root / "src/UnityVietnameseFontSync.csproj").write_text(
+ "1.0.0"
+ "zeldrisho.UnityVietnameseFontSync"
+ "")
+ (root / "CHANGELOG.md").write_text("## [1.0.0] - 2026-10-15\n### Added\n- Example.\n")
+ for name in ("README.md", "LICENSE", "docs/configuration.md", "docs/development.md"):
+ (root / name).write_text("example")
+ (root / "README.md").write_text("See [configuration](docs/configuration.md).")
+ (root / "private.ttf").write_text("must not ship")
+ dll = root / "UnityVietnameseFontSync.dll"
+ dll.write_bytes(b"test fixture")
+ first = package(root, "v1.0.0", dll, root / "out1")
+ second = package(root, "v1.0.0", dll, root / "out2")
+ self.assertEqual(first.name, "UnityVietnameseFontSync-1.0.0.zip")
+ self.assertEqual(first.read_bytes(), second.read_bytes())
+ prefix = "plugins/zeldrisho.UnityVietnameseFontSync/"
+ with zipfile.ZipFile(first) as bundle:
+ self.assertEqual(set(bundle.namelist()), {
+ prefix + name for name in ("UnityVietnameseFontSync.dll", "README.md", "CHANGELOG.md", "LICENSE")
+ })
+ self.assertNotIn(b"docs/configuration.md", bundle.read(prefix + "README.md"))
+ self.assertEqual({file.name for file in first.parent.iterdir()}, {first.name, "notes.md"})
+ hosted = package(root, "v1.0.0", dll, root / "hosted", "https://github.com/example/project")
+ with zipfile.ZipFile(hosted) as bundle:
+ self.assertIn(b"https://github.com/example/project/blob/v1.0.0/docs/configuration.md",
+ bundle.read(prefix + "README.md"))
+ for tag in ("v1.0.1", "v1.0.0-beta", "../../evil", "v01.0.0"):
+ with self.assertRaises(ValueError):
+ package(root, tag, dll, root / "bad")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/FontRules.cs b/src/FontRules.cs
new file mode 100644
index 0000000..423daae
--- /dev/null
+++ b/src/FontRules.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace UnityVietnameseFontSync
+{
+ public enum MatchKind { Exact, Contains, All }
+ public enum PatchMode { Fallback, Synchronize }
+
+ public sealed class FontRule
+ {
+ public string Id;
+ public string Profile;
+ public string Pattern;
+ public MatchKind Match;
+ public PatchMode Mode;
+ public bool Matches(string name)
+ {
+ if (Match == MatchKind.All) return true;
+ if (string.IsNullOrEmpty(Pattern)) return false;
+ return Match == MatchKind.Exact
+ ? string.Equals(name, Pattern, StringComparison.OrdinalIgnoreCase)
+ : name.IndexOf(Pattern, StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+ }
+
+ public static class FontRules
+ {
+ public static string[] Split(string value)
+ {
+ var result = new List();
+ foreach (string part in (value ?? "").Split(','))
+ if (part.Trim().Length > 0 && !result.Contains(part.Trim())) result.Add(part.Trim());
+ return result.ToArray();
+ }
+
+ public static FontRule Select(string name, IEnumerable rules, string[] exclusions)
+ {
+ foreach (string exclusion in exclusions)
+ if (name.IndexOf(exclusion, StringComparison.OrdinalIgnoreCase) >= 0) return null;
+ foreach (FontRule rule in rules) if (rule.Matches(name)) return rule;
+ return null;
+ }
+
+ public static string ResolvePath(string file, string fontsDirectory, string customDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(file)) return null;
+ if (Path.IsPathRooted(file)) return file;
+ string custom = Path.Combine(customDirectory, file);
+ if (File.Exists(custom)) return custom;
+ return Path.Combine(fontsDirectory, file);
+ }
+
+ public static uint[] Characters(string extra)
+ {
+ var text = new StringBuilder();
+ for (int i = 32; i <= 126; i++) text.Append((char)i);
+ const string vowels = "aăâeêioôơuưyAĂÂEÊIOÔƠUƯY";
+ const string tones = "\u0300\u0301\u0303\u0309\u0323";
+ text.Append("đĐ").Append(vowels);
+ foreach (char vowel in vowels)
+ foreach (char tone in tones)
+ text.Append((vowel.ToString() + tone).Normalize(NormalizationForm.FormC));
+ // Combining marks are optional custom characters: source fonts may not support them.
+ text.Append(extra ?? "");
+ var result = new HashSet();
+ string value = text.ToString();
+ for (int i = 0; i < value.Length; i++)
+ {
+ if (char.IsHighSurrogate(value[i]) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))
+ result.Add((uint)char.ConvertToUtf32(value[i], value[++i]));
+ else if (!char.IsSurrogate(value[i])) result.Add(value[i]);
+ else throw new ArgumentException("Custom characters contain an unpaired surrogate.");
+ }
+ var codes = new uint[result.Count];
+ result.CopyTo(codes);
+ Array.Sort(codes);
+ return codes;
+ }
+ }
+}
diff --git a/src/PluginSettings.cs b/src/PluginSettings.cs
new file mode 100644
index 0000000..f3f5378
--- /dev/null
+++ b/src/PluginSettings.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Collections.Generic;
+using BepInEx.Configuration;
+
+namespace UnityVietnameseFontSync
+{
+ internal sealed class FontProfile
+ {
+ public string Id, File;
+ public int Size, Padding, Atlas;
+ public bool MultiAtlas;
+ }
+
+ internal sealed class PluginSettings
+ {
+ public readonly List Profiles = new List();
+ public readonly List Rules = new List();
+ public string[] Exclusions;
+ public uint[] Characters;
+ public float ScanInterval, RetryInterval;
+ public bool Enabled, Diagnostics;
+
+ public PluginSettings(ConfigFile config)
+ {
+ Enabled = config.Bind("General", "Enabled", true, "Enable plugin. Restart after configuration changes.").Value;
+ Diagnostics = config.Bind("General", "Diagnostics", false, "Log discovered font names once per live asset.").Value;
+ ScanInterval = config.Bind("General", "ScanInterval", 2f, new ConfigDescription("Seconds between discovery scans; 0 disables periodic scans.", new AcceptableValueRange(0f, 300f))).Value;
+ RetryInterval = config.Bind("General", "RetryInterval", 30f, new ConfigDescription("Seconds before retrying a failed font mutation.", new AcceptableValueRange(1f, 600f))).Value;
+ Exclusions = FontRules.Split(config.Bind("Rules", "Exclude", "", "Comma-separated asset-name fragments excluded before all rules; include your game's icon fonts here.").Value);
+ Characters = FontRules.Characters(config.Bind("Fonts", "CustomCharacters", "", "Extra characters in addition to ASCII and precomposed Vietnamese. No translated text is normalized.").Value);
+ foreach (string id in FontRules.Split(config.Bind("Fonts", "Profiles", "default", "Comma-separated profile IDs.").Value))
+ {
+ ValidateId(id);
+ string section = "Font." + id;
+ Profiles.Add(new FontProfile
+ {
+ Id = id,
+ File = config.Bind(section, "File", "", "Font filename or absolute path. Empty disables this profile. Relative paths search plugin Fonts then Windows Fonts.").Value,
+ Size = config.Bind(section, "SamplingSize", 36, new ConfigDescription("Font sampling size.", new AcceptableValueRange(8, 128))).Value,
+ Padding = config.Bind(section, "Padding", 4, new ConfigDescription("SDF padding.", new AcceptableValueRange(1, 32))).Value,
+ Atlas = config.Bind(section, "AtlasSize", 2048, "Square atlas size: 256, 512, 1024, 2048, or 4096.").Value,
+ MultiAtlas = config.Bind(section, "MultiAtlas", true, "Allow additional atlas textures.").Value
+ });
+ int atlas = Profiles[Profiles.Count - 1].Atlas;
+ if (atlas < 256 || atlas > 4096 || (atlas & (atlas - 1)) != 0) throw new ArgumentException(section + ": invalid AtlasSize.");
+ }
+ foreach (string id in FontRules.Split(config.Bind("Rules", "Order", "", "Comma-separated rule IDs in first-match order. Empty means no mutation.").Value))
+ {
+ ValidateId(id);
+ string section = "Rule." + id;
+ var rule = new FontRule
+ {
+ Id = id,
+ Profile = config.Bind(section, "Profile", "default", "Profile ID to use.").Value,
+ Pattern = config.Bind(section, "Pattern", "", "Case-insensitive font asset name or fragment.").Value,
+ Match = config.Bind(section, "Match", MatchKind.Exact, "Exact, Contains, or All. All explicitly matches every non-excluded asset.").Value,
+ Mode = config.Bind(section, "Mode", PatchMode.Fallback, "Fallback preserves glyphs; Synchronize clears all original characters and freezes the original atlas. Opt-in only.").Value
+ };
+ if (!Enum.IsDefined(typeof(MatchKind), rule.Match) || !Enum.IsDefined(typeof(PatchMode), rule.Mode)) throw new ArgumentException(section + ": invalid mode.");
+ if (!Profiles.Exists(p => p.Id == rule.Profile)) throw new ArgumentException(section + ": unknown profile " + rule.Profile);
+ if (rule.Match != MatchKind.All && string.IsNullOrWhiteSpace(rule.Pattern)) throw new ArgumentException(section + ": Pattern must not be empty.");
+ Rules.Add(rule);
+ }
+ }
+
+ private static void ValidateId(string id)
+ {
+ foreach (char c in id)
+ if (!char.IsLetterOrDigit(c) && c != '_' && c != '-') throw new ArgumentException("Profile/rule IDs must contain only letters, digits, underscores or hyphens: " + id);
+ }
+ }
+}
diff --git a/src/TmpRuntime.cs b/src/TmpRuntime.cs
new file mode 100644
index 0000000..eb97a84
--- /dev/null
+++ b/src/TmpRuntime.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections;
+using System.Linq;
+using System.Reflection;
+
+namespace UnityVietnameseFontSync
+{
+ // This adapter deliberately has no compile-time dependency on a game's TMP assembly.
+ internal sealed class TmpRuntime
+ {
+ public Type FontType { get; }
+ public Type TextType { get; }
+ private readonly MethodInfo _create, _addCharacters, _readDefinition, _refresh, _dirty;
+ private readonly PropertyInfo _characters, _fallbacks, _mode, _textures, _textFont;
+ private readonly MemberInfo _material;
+
+ public TmpRuntime(Type fontType, Type textType, Type unityFontType)
+ {
+ FontType = fontType;
+ TextType = textType;
+ _characters = RequireProperty(fontType, "characterTable");
+ _fallbacks = RequireProperty(fontType, "fallbackFontAssetTable", true);
+ _mode = RequireProperty(fontType, "atlasPopulationMode", true);
+ _textures = RequireProperty(fontType, "atlasTextures");
+ _textFont = RequireProperty(textType, "font");
+ _material = (MemberInfo)fontType.GetProperty("material") ?? fontType.GetField("material");
+ if (_material == null) throw new NotSupportedException("TMP font material is unavailable.");
+ _create = fontType.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(method =>
+ {
+ var p = method.GetParameters();
+ return method.Name == "CreateFontAsset" && p.Length >= 8 &&
+ p[0].ParameterType == unityFontType && p[1].ParameterType == typeof(int) &&
+ p[2].ParameterType == typeof(int) && p[3].ParameterType.IsEnum &&
+ p[4].ParameterType == typeof(int) && p[5].ParameterType == typeof(int) &&
+ p[6].ParameterType == _mode.PropertyType && p[7].ParameterType == typeof(bool) &&
+ p.Skip(8).All(parameter => parameter.HasDefaultValue);
+ });
+ if (_create == null) throw new NotSupportedException("TMP lacks a compatible runtime Font-to-SDF creation overload.");
+ _addCharacters = RequireMethod(fontType, "TryAddCharacters", typeof(uint[]), typeof(uint[]).MakeByRefType(), typeof(bool));
+ _readDefinition = RequireMethod(fontType, "ReadFontAssetDefinition");
+ _refresh = RequireMethod(textType, "ForceMeshUpdate", typeof(bool), typeof(bool));
+ _dirty = RequireMethod(textType, "SetAllDirty");
+ // Validate enum names at startup, before any game assets can be modified.
+ Enum.Parse(_mode.PropertyType, "Static");
+ Enum.Parse(_mode.PropertyType, "Dynamic");
+ Enum.Parse(_create.GetParameters()[3].ParameterType, "SDFAA");
+ }
+
+ public static TmpRuntime Discover(Type unityFontType)
+ {
+ foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ Type font = assembly.GetType("TMPro.TMP_FontAsset", false);
+ Type text = assembly.GetType("TMPro.TMP_Text", false);
+ if (font != null && text != null) return new TmpRuntime(font, text, unityFontType);
+ }
+ throw new NotSupportedException("No loaded TMP_FontAsset/TMP_Text API found. Legacy UI and IL2CPP are not supported.");
+ }
+
+ public object Create(object source, int size, int padding, int atlas, bool multiAtlas)
+ {
+ var parameters = _create.GetParameters();
+ var arguments = new object[parameters.Length];
+ object[] required = { source, size, padding, Enum.Parse(parameters[3].ParameterType, "SDFAA"),
+ atlas, atlas, Enum.Parse(_mode.PropertyType, "Dynamic"), multiAtlas };
+ Array.Copy(required, arguments, required.Length);
+ for (int i = required.Length; i < arguments.Length; i++) arguments[i] = parameters[i].DefaultValue;
+ return _create.Invoke(null, arguments);
+ }
+
+ public bool AddCharacters(object font, uint[] codes, out uint[] missing)
+ {
+ object[] arguments = { codes, null, false };
+ bool complete = (bool)_addCharacters.Invoke(font, arguments);
+ missing = (uint[])arguments[1];
+ return complete;
+ }
+
+ public IList Characters(object font) => (IList)_characters.GetValue(font, null);
+ public IList Fallbacks(object font) => (IList)_fallbacks.GetValue(font, null);
+ public void SetFallbacks(object font, IList value) => _fallbacks.SetValue(font, value, null);
+ public IList NewFallbacks() => (IList)Activator.CreateInstance(_fallbacks.PropertyType);
+ public object Mode(object font) => _mode.GetValue(font, null);
+ public void SetMode(object font, object mode) => _mode.SetValue(font, mode, null);
+ public void Freeze(object font) => SetMode(font, Enum.Parse(_mode.PropertyType, "Static"));
+ public IEnumerable Textures(object font) => (IEnumerable)_textures.GetValue(font, null);
+ public object Material(object font) => _material is PropertyInfo property
+ ? property.GetValue(font, null) : ((FieldInfo)_material).GetValue(font);
+ public object TextFont(object text) => _textFont.GetValue(text, null);
+ public void ReadDefinition(object font) => _readDefinition.Invoke(font, null);
+ public void Refresh(object text)
+ {
+ _dirty.Invoke(text, null);
+ _refresh.Invoke(text, new object[] { true, true });
+ }
+
+ private static PropertyInfo RequireProperty(Type type, string name, bool writable = false)
+ {
+ PropertyInfo property = type.GetProperty(name);
+ if (property == null || !property.CanRead || (writable && !property.CanWrite))
+ throw new NotSupportedException(type.FullName + "." + name + " is unavailable.");
+ return property;
+ }
+
+ private static MethodInfo RequireMethod(Type type, string name, params Type[] parameters)
+ {
+ return type.GetMethod(name, parameters) ?? throw new NotSupportedException(type.FullName + "." + name + " has no compatible overload.");
+ }
+ }
+}
diff --git a/src/UnityVietnameseFontSync.csproj b/src/UnityVietnameseFontSync.csproj
new file mode 100644
index 0000000..1fdb869
--- /dev/null
+++ b/src/UnityVietnameseFontSync.csproj
@@ -0,0 +1,21 @@
+
+
+
+ netstandard2.1
+ UnityVietnameseFontSync
+ UnityVietnameseFontSync
+ Unity Vietnamese Font Sync
+ MIT
+ zeldrisho.UnityVietnameseFontSync
+ 1.0.0
+ latest
+ true
+
+
+
+
+
+
+
+
+
diff --git a/src/UnityVietnameseFontSyncPlugin.cs b/src/UnityVietnameseFontSyncPlugin.cs
new file mode 100644
index 0000000..4eb1caa
--- /dev/null
+++ b/src/UnityVietnameseFontSyncPlugin.cs
@@ -0,0 +1,242 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using BepInEx;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using Object = UnityEngine.Object;
+
+namespace UnityVietnameseFontSync
+{
+ [BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
+ public sealed class UnityVietnameseFontSyncPlugin : BaseUnityPlugin
+ {
+ private PluginSettings _settings;
+ private TmpRuntime _tmp;
+ private readonly Dictionary _profiles = new Dictionary();
+ private readonly List