diff --git a/mac-patcher/.gitignore b/mac-patcher/.gitignore deleted file mode 100644 index 34bac69b..00000000 --- a/mac-patcher/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -venv/ -__pycache__/ -*.pyc -*.bak -*.bak-* -scan_bundles_*.out diff --git a/mac-patcher/README.md b/mac-patcher/README.md deleted file mode 100644 index 420bd388..00000000 --- a/mac-patcher/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# mac-patcher - -Python patcher that injects Mac-compatibility fallbacks into a SWL TTS save -file. Works around the TTS Unity 6 (Berserk) shader bug where vanilla -AssetBundle Projectors (range rulers, cohesion halos, movement templates, -deployment zones, silhouettes) render as a magenta rectangle on macOS -because the custom shader bundled by Allen White (Dicewrench) fails to load -under Unity 6 Metal. - -## What it does - -For each affected object script in a `TS_Save_N.json`: - -- **Cohesion / Range / Maximum Move / Deployment / Silhouette**: routes the - visualization through a Mac-friendly path (vector lines + decal PNGs) - built into the Global script. The vanilla bundle path stays available - for per-seat opt-in (see "Mac TTS U6 Patch" floating panel). - -- **SIL button**: renames `click_function = "toggleSilhouettes"` to - `"macToggleSil"` + injects a forwarder. Empirically the engine silently - swallows the original click_function name in Mac mode (the button plays - its sound/animation but the function is never invoked). The rename - bypasses the issue entirely. - -- **clearSilhouette nil guard**: upstream `clearSilhouette()` derefs - `removeAttachments()[1]` which is nil when `silhouetteState` is true but - the physical silhouette attachment has been lost across save reloads. - Patched to guard the nil case + reset state in onload. - -- **Order Token Speed/Move button overrides**: inlines vanilla bodies of - `changeSpeed1/2/3` and `moveForward/Backwards/Left/Right` to capture - the clicking player's color (`macActivePlayerForMove`) for per-seat - routing. Inlined rather than wrapped because the List Builder copies the - block to Command Token Custom_Models which lack the vanilla - Order_Token function bodies (wrap+call_orig would crash with "attempt - to call a nil value"). - -## Usage - -```bash -# Setup once -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt - -# Patch a save -python3 patch_save_for_mac.py [--reload] -``` - -The patcher also mirrors the patched save into -`~/Library/Tabletop Simulator/Saves/TS_Save_N.json` (next free slot, or the -existing `SWL BETA - MAC PATCH` slot if present) so TTS picks it up in -Games -> Save & Load. - -`--reload` hot-pushes the patched scripts to a running TTS instance via the -External Editor API (no need to restart TTS). - -## Idempotence - -All patches are idempotent: a re-run on an already-patched save strips the -previous Mac patch block from Global (via marker regex) and re-injects -fresh. Per-object wrappers are also stripped before re-injection. - -## Files - -- `patch_save_for_mac.py` - main patcher -- `scan_bundles.py` - UnityPy scanner for the local TTS bundle cache -- `extract_projector_specs.py` - dump Projector material properties from a - bundle (used to discover the `_Arc` shader keyword for firing arc lines) -- `generate_overlay_assets.py` - regenerate the range/cohesion/etc. PNG - decals from authoritative SWL geometry -- `inspect_*.py` - debug helpers -- `retro-*.md` - retrospective notes on each subsystem rewrite -- `SHADER_INVENTORY.md` - list of custom shaders in the vanilla bundles diff --git a/mac-patcher/SHADER_INVENTORY.md b/mac-patcher/SHADER_INVENTORY.md deleted file mode 100644 index 926e86db..00000000 --- a/mac-patcher/SHADER_INVENTORY.md +++ /dev/null @@ -1,59 +0,0 @@ -# SW Legion TTS Mod — Shader Inventory - -Scan effectué le 2026-05-07 sur `~/Library/Tabletop Simulator/Mods/Assetbundles/` (40 bundles `.unity3d`). -Outil : UnityPy 1.25.0, script `scan_bundles.py`. Output brut : `scan_bundles_2026-05-07.out`. - -## TL;DR -**9 shaders custom distincts**, **tous embarqués dans le `.unity3d` qui les utilise**. Donc : -- L'astuce "Always Included Shaders" du player TTS est **non-applicable** : ces shaders ne sont pas chargés depuis le pool du player, ils vivent dans les bundles. -- Le bug magenta vient du fait que le bytecode/code source de ces shaders custom ne fonctionne plus correctement sous Unity 6 + Metal sur Mac. -- Les overlays magenta listés par Martin (cohésion, range, deployment, movement) correspondent **exactement** aux 4 shaders `BucketheadBits/Projector/*`. - -## Inventaire complet (par catégorie) - -### Shaders Projector (overlays affectés par le bug magenta) -| Shader | Materials | Bundles | -|---|---|---| -| `BucketheadBits/Projector/Movement` | `ProjectorMaterial_27mm_speed1_single`, `ProjectorMaterial_27mm_speed2_single`, `ProjectorMaterial_50mm_speed2_single` | 3 | -| `BucketheadBits/Projector/Deployment` | `Projector_Deployment_Blue` (×2), `Projector_Deployment_Red` (×2) | 4 | -| `BucketheadBits/Projector/Range` | `ProjectorMaterial_25mm_token`, `ProjectorMaterial_27mm`, `ProjectorMaterial_50mm` | 3 | -| `BucketheadBits/Projector/Cohesion` | `Cohesion_27mm` | 1 | - -→ 11 materials projector au total, 4 shaders distincts à porter en priorité. - -### Outils / autres custom -| Shader | Materials | Bundles | -|---|---|---| -| `BucketheadBits/MoveTool` | `Speed1`, `Speed2` | 2 | -| `BucketheadBits/Tokens/Lambert Channel Mask` | `unitIDtoken_1` à `unitIDtoken_10` | 10 | -| `BucketheadBits/Silhouette` | `BucketheadBits_Silhouette` | 1 | -| `BucketheadBits/Units/Color Replacer` | `Ewok_SharedMat` | 1 | -| `DWD/LightenSkybox` | `Skybox` | 1 | - -→ Le shader `Tokens/Lambert Channel Mask` correspond aux 10 unitIDtokens loggés en console par TTS lors du bug : "Shader didn't load correctly for AssetBundle material unitIDtoken_X". Donc ce shader est **explicitement confirmé cassé**. - -## Auteurs -- Préfixe **`BucketheadBits/`** sur 8/9 shaders → ancien contributeur "Buckethead". Aucun repo GitHub public trouvé sous ce handle. Possible que ce soit un handle alternatif de Tieren (crédité ailleurs comme "original mod creator"). -- Préfixe **`DWD/`** sur 1 shader (LightenSkybox) → autre auteur, juste un tint skybox. - -## Implications pour la conversation avec Ben - -1. **L'angle "Always Included Shaders" n'est pas la bonne piste** pour ce bug, parce que les shaders custom sont embarqués dans les bundles. Si Ben a essayé cette piste, c'est cohérent que ça n'ait rien donné. - -2. **Le scope minimum réaliste** : porter / remplacer ces 9 shaders sous Unity 6 (compilation Metal-compatible). Soit en partant des sources s'il les a, soit en réécrivant des équivalents stock. - -3. **Materials = 11 projectors + 10 tokens + 4 outils + 1 skybox = 26 materials affectés au total** dans 27 bundles différents. Volumétrie raisonnable si les shaders sont swappable d'un coup. - -4. **Type technique des shaders** : - - Les `Projector/*` sont des shaders Unity Projector (transparent, projection avec bandes alternées) — historiquement tricky à porter Metal. - - Le `Tokens/Lambert Channel Mask` est un shader de coloration par canaux (R/G/B/A) — utilisé pour appliquer la couleur du joueur sur un mesh à canaux. Classique mais codé custom. - - Les autres (MoveTool, Silhouette, Color Replacer, LightenSkybox) sont des shaders d'effets simples. - -## Sources non-trouvées -- Aucun repo public sous `swlegion`, `swlegion-dev`, `matanlurey`, `halestom15`, `Buckethead` ne contient les sources Unity de ces shaders. -- Le projet Unity vit donc en privé sur la machine d'un contributeur (probablement Decaf ou Ben). - -## Prochaine étape -Attendre Ben pour confirmer si les sources sont disponibles. Cet inventaire répond déjà partiellement à ses questions 1 et 2 : -- Q1 ("custom shaders updated or just retargeted ?") : on sait maintenant exactement ce qu'il faut updater. -- Q2 ("which shaders are referenced ?") : liste complète ci-dessus. diff --git a/mac-patcher/design-refactor.md b/mac-patcher/design-refactor.md deleted file mode 100644 index a94b2d03..00000000 --- a/mac-patcher/design-refactor.md +++ /dev/null @@ -1,330 +0,0 @@ -# Design refactor — Overlays Projector SWL TTS (Mac/Unity 6) - -**Status** : draft 15 mai 2026. Spec pour le patch event-driven hybride (decals plats + vector lines drapants). Base : 4 retro-*.md + materials-reference.md + shaders Allen White lus. - -## 1. Contexte (résumé exécutif) - -Le passage du player TTS à Unity 6 a strippé les variants Standard receiver des Projectors legacy. Sur Mac, **tous les overlays Projector du mod SWL s'affichent en magenta** : Cohesion Ruler, Range Ruler, Maximum Move, Deployment Boundary. Validation empirique session 14-15 mai : aucun fix côté bundle/shader ne marche (le strip est player-side TTS U6). Fix Berserk = ticket à ouvrir mais sans garantie de calendrier. - -**Solution mod-side validée** : remplacer le pattern Custom_AssetBundle (Projector) par un combo **Decal plat (PNG hébergé) + Vector lines (drapant via raycast)** orchestré par un manager Global event-driven. - -## 1bis. Fallback per-seat avec auto-test au boot - -Le refactor ne **remplace pas** le Projector legacy, il l'**augmente** d'un fallback per-seat. Chaque joueur a un compat flag détecté au boot, et le rendu s'adapte : - -- **Seats non-Mac** (TTS U6 OK) → voient le **Projector legacy** (rendu original préservé) -- **Seats Mac** (bug magenta) → voient le **patch hybride** (decal + vector lines) - -Quand Berserk corrigera le bug, l'auto-test au boot suivant désactivera le compat pour les seats Mac → retour automatique au comportement legacy, zéro action utilisateur. - -### 1bis.1 État par-seat (Global script) - -```lua -compatBySeat = {} -- {Red=true|false, Blue=true|false, ...} true = Mac/needs patch - -function onLoad(script_state) - if script_state ~= "" then - compatBySeat = JSON.decode(script_state) or {} - end - testCompatForAllSeated() -end - -function onSave() - return JSON.encode(compatBySeat) -end - -function getMacSeats() - local out = {} - for color, isMac in pairs(compatBySeat) do - if isMac then table.insert(out, color) end - end - return out -end - -function getNonMacSeats() - local out = {} - for color, isMac in pairs(compatBySeat) do - if not isMac then table.insert(out, color) end - end - return out -end -``` - -### 1bis.2 Test compat au boot - -```lua -function testCompatForAllSeated() - for _, player in pairs(Player.getPlayers()) do - if player.seated then - offerCompatTest(player) - end - end -end - -function offerCompatTest(player) - -- Spawn un canary Projector en zone hors-jeu (sous la table) - local canary = spawnObject({ - type="Custom_AssetBundle", - position={-50, -10, -50}, - scale={0,0,0} - }) - canary.setCustomObject({assetbundle = COHESION_27MM_URL}) - - -- Wait quelques secondes le temps que le bundle charge - Wait.time(function() - -- UI popup pour ce joueur uniquement - player.broadcast("Test rendu : cliquez sur ce que vous voyez sur le canary (Y=-10)") - -- Buttons "Couleurs OK" et "Magenta/rose" - -- onClick stocke compatBySeat[player.color] = true|false - -- destruct canary - end, 3) -end -``` - -### 1bis.3 Spawn d'un overlay (pattern unifié) - -```lua -function spawnOverlay(type, fig, params) - local macSeats = getMacSeats() - local nonMacSeats = getNonMacSeats() - local entry = {type=type, fig=fig, params=params, objects={}} - - -- 1. Projector legacy pour les seats non-Mac - if #nonMacSeats > 0 then - local projector = spawnObject({type="Custom_AssetBundle", ...}) - projector.setCustomObject({assetbundle = bundleURLs[type][fig.baseSize]}) - projector.setLock(true) - projector.setName(type .. "_legacy") - if #macSeats > 0 then projector.setInvisibleTo(macSeats) end - entry.objects.projector = projector - end - - -- 2. Custom_Tile (PNG plat) pour les seats Mac - if #macSeats > 0 then - local tile = spawnObject({type="Custom_Tile", position=fig.pos+Y0.1, scale=...}) - tile.setCustomObject({image = pngURLs[type][fig.baseSize]}) - tile.setLock(true) - tile.setName(type .. "_patch") - if #nonMacSeats > 0 then tile.setInvisibleTo(nonMacSeats) end - entry.objects.tile = tile - end - - -- 3. Vector lines (drape relief) pour les seats Mac - if #macSeats > 0 then - entry.lines = buildVectorLines(type, fig, params, macSeats) - -- ajoutées au batch global - end - - activeOverlays[fig.getGUID() .. ":" .. type] = entry - redrawAll() -end -``` - -### 1bis.4 Bug Berserk fix → reset auto - -Quand Berserk corrigera les variants Standard receiver dans le player TTS U6 : -- Au prochain boot de partie, `testCompatForAllSeated` re-run pour chaque seat -- Les Mac users verront maintenant le canary correctement → cliquent "Couleurs OK" → `compatBySeat[color] = false` -- Le patch ne s'active plus pour eux, comportement legacy restauré -- Aucun changement de code requis côté mod - -**Re-test manuel** : un bouton "Re-test compatibility" dans l'UI Notes du mod permet de forcer un nouveau test à tout moment (utile si le user veut vérifier après une mise à jour TTS). - -### 1bis.5 Incertitudes à valider empiriquement au POC - -| Incertitude | Plan B si KO | -|---|---| -| `Object.setInvisibleTo` cache-t-il le **rendu de la projection** d'un Projector ou seulement le GameObject parent ? | Si KO : si au moins 1 Mac seat présent, on ne spawn PAS le Projector du tout (tout le monde voit le patch — compromis : 1 Mac dans la partie = tout le monde adopte le rendu patch) | -| `Global.setDecals()` supporte-t-il un filtre `players` per entry ? | Si KO : utiliser `Custom_Tile` Objects (déjà ce qu'on a au-dessus, avec `setInvisibleTo`). Plus propre et déjà compatible per-seat | - -À tester en début de phase d'implémentation, avant de pousser tout le code. - -## 2. Architecture event-driven (manager Global) - -### 2.1 État global unique - -```lua --- mod/src/includes/Overlays.ttslua (nouveau) -local activeOverlays = {} -- clé = "{guid}:{type}" → {type, fig, params} -local hiddenWhilePickedUp = {} -- buffer pour les overlays cachés temporairement -``` - -### 2.2 API publique unifiée - -```lua --- Spawn / clear par type, scopé à une fig -function spawnOverlay(type, fig, params) -function clearOverlayForFig(type, fig) -function clearAllOverlays() -- reset complet - --- Adapters pour conserver l'API existante des callers -function spawnCohesionRuler(fig) -- → spawnOverlay("cohesion", fig, {}) -function clearCohesionRuler() -- → clearOverlayForFig("cohesion", self) -function spawnRangeRuler(fig, override) -- → spawnOverlay("range", fig, {override=override}) -function clearRangeRulers() -- → clearOverlayForFig("range", selectedUnitObj) --- etc. -``` - -### 2.3 Event handlers (Global) - -```lua -function onObjectPickUp(player_color, obj) - -- buffer tous les overlays attachés à obj puis les retirer du draw - hidePickedUp(obj) - redrawAll() -end - -function onObjectDrop(player_color, obj) - -- restaurer les overlays de obj depuis le buffer - restorePickedUp(obj) - redrawAll() -end - -function onObjectDestroy(obj) - -- nettoyage définitif - purgeForFig(obj) - redrawAll() -end -``` - -### 2.4 Rendu unique par tick d'event - -```lua -function redrawAll() - local lines, decals = {}, {} - for _, entry in pairs(activeOverlays) do - local l, d = builders[entry.type](entry.fig, entry.params) - for _, line in ipairs(l) do table.insert(lines, line) end - for _, decal in ipairs(d) do table.insert(decals, decal) end - end - Global.setVectorLines(lines) - Global.setDecals(decals) -end -``` - -**Important** : `setVectorLines` et `setDecals` sont des **singletons** côté TTS — chaque appel remplace toute la collection. D'où le manager global qui agrège. - -## 3. Spec par overlay - -### 3.1 Cohesion - -| Composant | Détail | -|---|---| -| Trigger spawn | Hotkey "Show Cohesion On Hovered Model" / bouton COHESION Order Token / dropCoroutine post-mouvement (`moveState=true`) | -| Trigger clear | Mêmes triggers (toggle) + onPickedUp (auto) + standby/reload globaux | -| Decal PNG | `cohesion_halo.png` — gradient radial blanc fade depuis le centre, fond transparent, 512×512px | -| Decal params | `position = fig.pos + Y0.1`, `rotation = (90, 0, 0)` (face vers le haut), `scale = (baseRadius+1, baseRadius+1, baseRadius+1) × 2` | -| Vector line | 1 cercle filaire blanc à `r = base_radius + 0.5"` du centre, drapé par raycast 32 segments | -| Couleur | #FFFFFF α=0.6 | - -API conservée : `showCohesionOnHoveredModel`, `spawnCohesionRuler`, `clearCohesionRuler`. Compatible avec les 3 sites de require existants (Global, Unit_Leader, Order_Token) car redirigés vers le manager global. - -### 3.2 Range Ruler - -| Composant | Détail | -|---|---| -| Trigger spawn | Hotkey "Show Range On Hovered Model" / bouton RANGE Order Token (targetingMode/attackMode) / bouton R sur POI/tokens | -| Trigger clear | Mêmes triggers (toggle) + clearTemplates (post-drop mouvement) + exit modes | -| Decal PNG | `range_bands.png` (universel) — 4 anneaux concentriques à 6/12/18/24 inches relatifs, couleurs jaune/orange/rouge/magenta foncé, fond transparent, 1024×1024px | -| Decal params | `position = fig.pos + Y0.1`, `scale = (60, 60, 60)` (couvre 24" de rayon + marge) | -| Vector lines | 4 cercles filaires aux rayons absolus **6", 12", 18", 24"** du centre, mêmes couleurs que les bandes du PNG | -| Couleur (par anneau) | R1 #FFC300, R2 #FF7500, R3 #FF1400, R4 #C20042 (alpha 0.6 sur les lignes) | -| Variantes | Tokens (smokeToken/token/tokenRangeTwo/bombCart/POI) → moins d'anneaux (1 ou 2), PNG distincts ou même PNG avec scale ajusté | - -API conservée : `showRangeOnHoveredModel`, `spawnRangeRuler(fig, override)`, `clearRangeRuler/clearRangeRulers`. - -Pour les variantes, le `override` permet de passer un type explicite (`"smokeToken"`, `"poi"`, etc.) au lieu du baseSize de la fig. - -### 3.3 Maximum Move - -| Composant | Détail | -|---|---| -| Trigger spawn | Lors du clic sur bouton speed (1/2/3) sur Order Token (flow mouvement) | -| Trigger clear | clearMovementTemplates (drop final) | -| Decal PNG | `max_move_disk.png` — disque plein bleu ciel #55CCFF α=0.48, fond transparent, 512×512px | -| Decal params | `position = fig.pos + Y0.1`, `scale = ProjectorRadius × 2` selon (baseSize, speed) | -| Vector line | 1 cercle filaire bleu ciel au rayon = ProjectorRadius (drape relief) + 1 anneau base blanc fin à r = baseRadius + 0.5" | -| Couleur | Cercle principal #55CCFF α=0.6, anneau base #FFFFFF α=0.5 | -| Rayons | Lookup table `maxMoveRadius[baseSize][speed]` (ex: `27mm.speed1=4.55", speed2=6.52", speed3=8.48"`) — extraits de `_ProjectorRadius` dans les materials Movement | - -API conservée : flow existant dans `Order_Token.a57c41.lua:552-574`, remplacement du `spawnObject Custom_AssetBundle` par `spawnOverlay("maxmove", fig, {speed=selectedSpeed, baseSize=unitData.baseSize})`. - -### 3.4 Deployment Boundary - -| Composant | Détail | -|---|---| -| Trigger spawn | `spawnDeploymentBoundary(matrix)` au setup (depuis menu UI Deployment) | -| Trigger clear | `clearDeploymentBoundary()` (menu UI Remove Overlay) + standby global | -| Decal PNG | `deployment_red.png` + `deployment_blue.png` — rectangle plein uniforme α=0.5, 256×256px | -| Decal params | Par cellule de la matrix, `position = grid pos + offset`, `scale = (6, 6, 6)` (taille standard d'une cellule SWL), rotation selon `deployRotations[cell]` | -| Vector lines | Contour rectangle drapant (4 côtés × N segments via raycast) par cellule, couleur match decal | -| Couleur | Rouge #FF0000 / Bleu #0000FF α=0.5 | -| Variantes | Half/L/Corner/Round → combinaisons de cellules dans la matrix, le manager gère chaque cellule individuellement (1 decal + 1 contour par cellule) | - -API conservée : `spawnDeploymentBoundary(matrix)`, `clearDeploymentBoundary()`. Internement, chaque cellule est enregistrée comme un overlay distinct avec un GUID synthétique (ex `"deployment:bs:5:3"`). - -## 4. Assets PNG à générer - -| Fichier | Dimensions | Description | Hosting | -|---|---|---|---| -| `cohesion_halo.png` | 512×512 | Gradient radial blanc centre → transparent bord, RGBA | iron-squadron.fr/tts-assets/ | -| `range_bands.png` | 1024×1024 | 4 anneaux concentriques aux rayons relatifs 0.25/0.5/0.75/1.0, gradient discret (pas blend), couleurs jaune/orange/rouge/magenta, alpha 0.6 | iron-squadron.fr/tts-assets/ | -| `range_smoke.png` | 256×256 | 1 anneau à rayon relatif 1.0, blanc opaque | idem | -| `range_token.png` | 256×256 | 1 anneau à rayon relatif 1.0, jaune | idem | -| `range_tokenRangeTwo.png` | 256×256 | 2 anneaux, jaune + orange | idem | -| `range_poi.png` | 256×256 | 1 anneau pour POI (range 0.5 / 3") | idem | -| `range_bombCart.png` | 256×256 | 2 anneaux | idem | -| `max_move_disk.png` | 512×512 | Disque plein bleu ciel #55CCFF α=0.48, RGBA | idem | -| `deployment_red.png` | 256×256 | Carré rouge plein #FF0000 α=0.5 | idem | -| `deployment_blue.png` | 256×256 | Carré bleu plein #0000FF α=0.5 | idem | - -**Génération** : script Python Pillow `generate_overlay_assets.py` qui produit tous les PNG d'un coup. Reproductible, versionnable dans `swlegion-tts/tool/`. - -**Upload** : `rsync` vers le VPS Iron Squadron, dossier `/var/www/iron-squadron/tts-assets/` (ou similaire). URL stable indépendante de Steam. - -## 5. Edge cases et invariants - -| Cas | Gestion | -|---|---| -| Multi-overlays simultanés (plusieurs figs avec cohesion + range) | Cohabitent dans la table `activeOverlays`, un `redrawAll()` au moindre changement | -| Fig détruite (mort en combat) | `onObjectDestroy` → `purgeForFig` → redraw | -| Pickup pendant overlay actif | `onObjectPickUp` → buffer dans `hiddenWhilePickedUp`, retire du rendu | -| Drop après pickup | `onObjectDrop` → restore depuis buffer, redraw (positions recalculées par les builders) | -| Pickup d'un objet sans overlay | Handler skip silencieux (test sur présence dans la table) | -| Reload de partie | `activeOverlays` se reset au boot (variable Lua locale du Global), OK | -| Filtres `standbyTokens` / `removeLockedRulers` (scans `getAllObjects()` par nom) | Deviennent caducs (plus d'Object physique). À supprimer ou laisser comme no-op | -| Test multi-joueurs réseau | Le manager Global est synchronisé par TTS (Lua sync), donc tous les joueurs voient les mêmes overlays | -| Drape impossible sur certains terrains custom | Limitation acceptable (déjà flag par Ben). Le decal plat sert de fallback visuel | - -## 6. Chemin de migration (séquentiel, testable étape par étape) - -1. **Setup Manager Global** : créer `mod/src/includes/Overlays.ttslua`. Aucun caller encore branché. Test : `npm run compile && load mod`, no-op confirmé -2. **Cohesion** (60 lignes, le plus simple) : réécrire `mod/src/includes/Cohesion.ttslua` pour rediriger vers le manager. Test : spawn via hotkey + bouton + dropCoroutine sur Mac -3. **Range Ruler** : réécrire `mod/src/includes/RangeRulers.ttslua` + adapter `POI_Token.lua` + `TokenWithRangeRuler.ttslua`. Test : 4 anneaux + tokens -4. **Maximum Move** : adapter `Order_Token.a57c41.lua:552-574` (spawn inline → call manager) + `clearMovementTemplates`. Test : flow mouvement complet sur Mac -5. **Deployment** : réécrire `SETUP_CONTROLLER.1cb552.lua:243-323`. Test : sélection scenario + spawn de la zone -6. **Cleanup** : supprimer les filtres `standbyTokens` / `removeLockedRulers` pour les types qui ne sont plus des Objects. Garder le filtre pour Movement Template (mesh, toujours Object) -7. **PR sur swlegion/tts** : branche `mac-overlays-refactor`, description du bug + solution + diff. Test communauté - -## 7. Compatibilité conservée - -- **Tous les sites de require existants** continuent à fonctionner sans modif (`require('!/Cohesion')`, `require('!/RangeRulers')`) -- **Toutes les fonctions globales conservent leur signature** (`spawnCohesionRuler(fig)`, `clearRangeRulers()`, etc.) -- **Pas de change d'API publique** → callers (Unit_Leader, Order_Token, POI_Token, etc.) inchangés -- **Variables d'état globales** comme `selectedUnitObj` conservées pour compat avec les filtres et la logique de toggle - -## 8. Risques connus à surveiller - -| Risque | Mitigation | -|---|---| -| Performance multi-overlays (N décals + N×64 vectors) | Tester empiriquement sur table chargée. Si lag, sparse raycast (32 segments) ou skip raycast si table plate détectée | -| Synchronisation decal/vector lines pas pixel-perfect | Vector lines au-dessus du decal (Y légèrement supérieur), tolérance visuelle | -| URL Iron Squadron offline | Hosting sur 2 destinations (VPS + GitHub Pages backup) ou inclure le CDN dans le repo si TTS supporte les data URIs | -| Bug U6 sur les decals aussi (improbable, POC validé le 14 mai) | Garder Path B = ticket Berserk en parallèle | -| Refactor casse un caller obscur | Test exhaustif des 5 sites de spawn + tous les triggers connus | - -## 9. Annexes - -- `materials-reference.md` : tous les params visuels extraits -- `retro-cohesion.md`, `retro-range.md`, `retro-deployment.md`, `retro-movement.md` : déclencheurs et call graphs -- `swlegion-tts/mod/src/includes/` : code source des modules à patcher diff --git a/mac-patcher/extract_projector_specs.py b/mac-patcher/extract_projector_specs.py deleted file mode 100644 index e699f8f1..00000000 --- a/mac-patcher/extract_projector_specs.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -""" -Scan Unity .mat and .prefab YAML files in UnityProject-U6/Assets/Projectors/ -and extract critical parameters for the SWL TTS mod refactor. - -Output: materials-reference.md with structured tables per overlay. -""" -import os -import re -from pathlib import Path -from collections import defaultdict - -ROOT = Path("/Users/martinpourrat/MARTIN/Star Wars Legion/Mod TTS SWL/UnityProject-U6/Assets/Projectors") -OUT = Path("/Users/martinpourrat/MARTIN/Star Wars Legion/Mod TTS SWL/materials-reference.md") - -OVERLAYS = ["Cohesion", "Range", "Deployment", "Movement"] - - -def read(p: Path) -> str: - return p.read_text(encoding="utf-8", errors="replace") - - -def parse_material(path: Path) -> dict: - """Extract critical fields from a Unity .mat YAML file.""" - text = read(path) - out = {"path": str(path.relative_to(ROOT)), "name": path.stem} - - m = re.search(r"m_ShaderKeywords:\s*(.*)", text) - out["keywords"] = m.group(1).strip() if m else "" - - m = re.search(r"m_Shader:\s*\{fileID:\s*\d+,\s*guid:\s*([0-9a-f]+)", text) - out["shader_guid"] = m.group(1) if m else "" - - # Floats — capture key params - floats = {} - for fname in ["_BaseSize", "_ProjectorRadius", "_BandSize", "_BandContrast", - "_MaxRange", "_Arc", "_RangeSize", "_GradScaler", - "_OneTuner", "_TwoTuner", "_ThreeTuner", "_FourTuner", - "_FiveTuner", "_InfinityTuner"]: - m = re.search(rf"-\s*{re.escape(fname)}:\s*(-?[\d.]+)", text) - if m: - floats[fname] = m.group(1) - out["floats"] = floats - - # Colors — capture all _Color* / _Range* - colors = {} - for m in re.finditer(r"-\s*(_\w+):\s*\{r:\s*([\d.]+),\s*g:\s*([\d.]+),\s*b:\s*([\d.]+),\s*a:\s*([\d.]+)\}", text): - name, r, g, b, a = m.groups() - if name in ("_BumpScale",): # not a color - continue - colors[name] = (float(r), float(g), float(b), float(a)) - out["colors"] = colors - - return out - - -def parse_prefab(path: Path) -> dict: - """Extract critical fields from a Unity .prefab YAML file (Projector components).""" - text = read(path) - out = {"path": str(path.relative_to(ROOT)), "name": path.stem} - - # Count Projector components (Unity type id 119) - projectors = re.findall(r"---\s*!u!119\s*&\d+\s*\nProjector:(.*?)(?=\n---|\Z)", text, re.DOTALL) - out["n_projectors"] = len(projectors) - - proj_data = [] - for body in projectors: - d = {} - for fname in ["m_OrthographicSize", "m_FarClipPlane", "m_NearClipPlane", - "m_FieldOfView", "m_AspectRatio", "m_Orthographic"]: - m = re.search(rf"{fname}:\s*(-?[\d.]+)", body) - if m: - d[fname] = m.group(1) - m = re.search(r"m_Material:\s*\{fileID:\s*\d+,\s*guid:\s*([0-9a-f]+)", body) - if m: - d["material_guid"] = m.group(1) - proj_data.append(d) - out["projectors"] = proj_data - - # Transforms (LocalPosition Y, rotation) - transforms = [] - for body in re.findall(r"---\s*!u!4\s*&\d+\s*\nTransform:(.*?)(?=\n---|\Z)", text, re.DOTALL): - d = {} - m = re.search(r"m_LocalPosition:\s*\{x:\s*(-?[\d.]+),\s*y:\s*(-?[\d.]+),\s*z:\s*(-?[\d.]+)\}", body) - if m: - d["pos"] = (float(m.group(1)), float(m.group(2)), float(m.group(3))) - m = re.search(r"m_LocalEulerAnglesHint:\s*\{x:\s*(-?[\d.]+),\s*y:\s*(-?[\d.]+),\s*z:\s*(-?[\d.]+)\}", body) - if m: - d["rot"] = (float(m.group(1)), float(m.group(2)), float(m.group(3))) - transforms.append(d) - out["transforms"] = transforms - - return out - - -def rgba_to_hex(r, g, b, a): - """Convert 0-1 RGBA to hex + alpha note.""" - rh, gh, bh = int(r * 255), int(g * 255), int(b * 255) - return f"#{rh:02X}{gh:02X}{bh:02X} (α={a:.2f})" - - -def fmt_colors(colors): - """Format dict of colors as compact markdown.""" - if not colors: - return "—" - parts = [] - for name, (r, g, b, a) in colors.items(): - if a == 0 and r == 0 and g == 0 and b == 0: - parts.append(f"`{name}` transparent") - else: - parts.append(f"`{name}` {rgba_to_hex(r, g, b, a)}") - return "
".join(parts) - - -def fmt_floats(floats): - """Format dict of floats.""" - if not floats: - return "—" - return ", ".join(f"`{k}`={v}" for k, v in floats.items()) - - -def main(): - materials_by_overlay = defaultdict(list) - prefabs_by_overlay = defaultdict(list) - - for overlay in OVERLAYS: - for p in sorted((ROOT / overlay).rglob("*.mat")): - if p.suffix == ".meta": - continue - materials_by_overlay[overlay].append(parse_material(p)) - for p in sorted((ROOT / overlay).rglob("*.prefab")): - if p.suffix == ".meta": - continue - prefabs_by_overlay[overlay].append(parse_prefab(p)) - - # Build the master doc - lines = [] - lines.append("# Materials & Prefabs Reference — SWL TTS Projector Overlays") - lines.append("") - lines.append("Extraction directe des `.mat` et `.prefab` Unity (YAML) dans `UnityProject-U6/Assets/Projectors/`. Source de vérité pour reproduire le rendu en vector lines.") - lines.append("") - lines.append("Notation : couleurs en hex + alpha. Champs `Floats` clés du shader projector (paramètres procéduraux des anneaux).") - lines.append("") - - for overlay in OVERLAYS: - lines.append(f"## {overlay}") - lines.append("") - - # Materials table - lines.append("### Materials") - lines.append("") - lines.append("| Material | Shader keywords | Floats clés | Couleurs |") - lines.append("|---|---|---|---|") - for m in materials_by_overlay[overlay]: - kw = m["keywords"] or "—" - lines.append(f"| `{m['name']}` | `{kw}` | {fmt_floats(m['floats'])} | {fmt_colors(m['colors'])} |") - lines.append("") - - # Prefabs table - lines.append("### Prefabs") - lines.append("") - lines.append("| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid |") - lines.append("|---|---|---|---|---|") - for p in prefabs_by_overlay[overlay]: - n = p["n_projectors"] - proj_sizes = [pr.get("m_OrthographicSize", "?") for pr in p["projectors"]] - proj_far = [pr.get("m_FarClipPlane", "?") for pr in p["projectors"]] - proj_summary = "
".join(f"size={s}, far={f}" for s, f in zip(proj_sizes, proj_far)) if proj_sizes else "—" - - tr_summary = "
".join( - f"pos=({t.get('pos', ('?',)*3)[0]}, {t.get('pos', ('?',)*3)[1]}, {t.get('pos', ('?',)*3)[2]}) rot=({t.get('rot', ('?',)*3)[0]}, {t.get('rot', ('?',)*3)[1]}, {t.get('rot', ('?',)*3)[2]})" - for t in p["transforms"] - ) if p["transforms"] else "—" - - mat_guids = [pr.get("material_guid", "?") for pr in p["projectors"]] - mat_summary = "
".join(g[:12] + "…" for g in mat_guids) if mat_guids else "—" - - lines.append(f"| `{p['name']}` | {n} | {proj_summary} | {tr_summary} | {mat_summary} |") - lines.append("") - - # Final notes - lines.append("## Notes globales") - lines.append("") - lines.append("- Cohesion : double set `Materials/cohesion_*` et `_Revamp/halfCohesion_*`. Probable : `_Revamp/` est la version actuellement utilisée (le bundle live s'appelle `halfcohesion_27mm.unity3d` cf shader-inventory).") - lines.append("- Range : shader procédural avec jusqu'à 5 bandes (`_RangeOne` à `_RangeFive`) + `_RangeInfinity`. Le keyword `_MAXRANGE_RANGEFIVE` ou `_MAXRANGE_RANGEFOUR` détermine la dernière bande visible.") - lines.append("- Movement : un seul Projector par prefab, paramétré par `_BaseSize` + speed. Le `_Arc` doit contrôler la portion d'arc visible.") - lines.append("- Deployment : 6 materials uniques réutilisés par 12 prefabs (rotations différentes pour matérialiser corner/half/L/round/score).") - - OUT.write_text("\n".join(lines), encoding="utf-8") - print(f"Wrote {OUT}") - # Stats - total_mats = sum(len(v) for v in materials_by_overlay.values()) - total_prefabs = sum(len(v) for v in prefabs_by_overlay.values()) - print(f"Scanned {total_mats} materials and {total_prefabs} prefabs across {len(OVERLAYS)} overlays.") - - -if __name__ == "__main__": - main() diff --git a/mac-patcher/generate_overlay_assets.py b/mac-patcher/generate_overlay_assets.py deleted file mode 100644 index d11b1932..00000000 --- a/mac-patcher/generate_overlay_assets.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate PNG assets for the Mac Cohesion + Range overlay fallback. -""" -from pathlib import Path -from PIL import Image, ImageDraw -import math - -OUT_DIR = Path(__file__).parent / "tts-assets" -OUT_DIR.mkdir(exist_ok=True) - - -# Range band colors (R, G, B in 0..255) — from BB_RangeProjector defaults -RANGE_COLORS = { - "half": (255, 255, 255), # 0.5 / 3" — white - "one": (255, 195, 0), # R1 / 6" — yellow - "two": (255, 117, 0), # R2 / 12" — orange - "three": (255, 20, 0), # R3 / 18" — red - "four": (195, 0, 66), # R4 / 24" — magenta - "five": (134, 0, 112), # R5 / 30" — violet -} - -# Configurations per overlay type (bands = list of (radius_inches, color_key)) -# Edge-based convention: each band's effective world radius is band_r + base_r. -RANGE_BAND_CONFIGS = { - "fig_leader": { - "bands": [(3, "half"), (6, "one"), (12, "two"), - (18, "three"), (24, "four"), (30, "five")], - "max_radius": 30, - }, - # SWL ranges in inches: Range 1 = 6", Range 2 = 12", Range 0.5 (poi) = 3". - "smokeToken": {"bands": [(6, "one")], "max_radius": 6}, - "token": {"bands": [(6, "one")], "max_radius": 6}, - "tokenRangeTwo": {"bands": [(6, "one"), (12, "two")], "max_radius": 12}, - "poi": {"bands": [(3, "one")], "max_radius": 3}, - "bombCart": {"bands": [(6, "one"), (12, "two")], "max_radius": 12}, -} - -# Base radii (inches, half-diameter) per ROUND fig base size. Oblong bases -# (long, snail) live in OBLONG_DIMENSIONS_MM below — they don't use a single -# radius and don't generate round PNGs. -FIG_BASE_RADIUS_IN = { - "small": 27 / 2 / 25.4, - "medium": 50 / 2 / 25.4, - "large": 70 / 2 / 25.4, - "huge": 100 / 2 / 25.4, - "laat": 120 / 2 / 25.4, - "epic": 150 / 2 / 25.4, -} - -# Token base radii (inches), only needed for multi-band tokens. -TOKEN_BASE_RADIUS_IN = { - "tokenRangeTwo": 25.1 / 2 / 25.4, - "bombCart": 50.0 / 2 / 25.4, -} - - -def _with_base_offset(config: dict, base_r: float) -> dict: - """Shift band radii outward by base_r and grow max_radius accordingly so - the PNG band proportions match the world positions of the vector-line - rings (which are drawn at band_r + base_r).""" - return { - "bands": [(r + base_r, c) for r, c in config["bands"]], - "max_radius": config["max_radius"] + base_r, - } - - -def generate_cohesion_halo(size: int = 512) -> Path: - """Halo radial blanc fade transparent — reproduit le rendu Cohesion bundle. - - Le shader BB_CohesionProjector fait `rangeOne = (1 - grad)` puis multiplie - par alpha global. On reproduit ce fade : opaque au centre, transparent au - bord. - """ - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - pixels = img.load() - cx, cy = size / 2, size / 2 - max_r = size / 2 - - # Alpha global du material Cohesion 27mm = 0.37 ; on multiplie par le fade - base_alpha = 0.37 - - for y in range(size): - for x in range(size): - dx, dy = x - cx, y - cy - dist = math.sqrt(dx * dx + dy * dy) - r_norm = dist / max_r # [0, 1+] depuis le centre - if r_norm > 1.0: - continue # transparent au-delà du rayon - - # Fade radial : (1 - r) avec courbe douce - # Le shader fait (1 - grad) linéaire, on garde la même chose - fade = max(0.0, 1.0 - r_norm) - alpha = int(base_alpha * fade * 255) - pixels[x, y] = (255, 255, 255, alpha) - - out = OUT_DIR / "cohesion_halo.png" - img.save(out) - return out - - -def generate_range_band_png(name: str, config: dict, size: int = 1024, - alpha: float = 0.2) -> Path: - """Generate a circular range overlay with concentric bands. - - Drawing from outer-to-inner: each filled ellipse overlays the previous, - producing clean rings. - """ - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - cx = cy = size / 2 - max_r_pix = size / 2 - a = int(alpha * 255) - - for band_r, color_key in reversed(config["bands"]): - r_pix = (band_r / config["max_radius"]) * max_r_pix - r, g, b = RANGE_COLORS[color_key] - draw.ellipse( - [(cx - r_pix, cy - r_pix), (cx + r_pix, cy + r_pix)], - fill=(r, g, b, a), - ) - - out = OUT_DIR / f"range_{name}.png" - img.save(out) - return out - - -# Oblong base dimensions (mm). Used to render stadium-shape range overlays -# whose 6 bands stay concentric to a rectangular footprint with rounded caps. -OBLONG_DIMENSIONS_MM = { - "long": {"width": 100, "length": 175}, - "snail": {"width": 100, "length": 200}, -} - - -def generate_range_band_stadium_png(base_size: str, dims_mm: dict, - config: dict, alpha: float = 0.2, - pixels_per_inch: int = 16) -> Path: - """Generate a stadium-shape PNG with 6 concentric range bands. - - The PNG dimensions encode the *outermost* band's bounding box (so the - decal can be stretched in TTS using scale = {shortSpan, longSpan, 1} and - the bands stay aligned with the matching vector stadium outlines). - - A stadium = rounded rectangle whose corner radius equals half the - short dimension, producing a pill shape with semicircular caps on the - long-axis ends. Each band is one rounded_rectangle of width - 2*(halfWid + band_r) and length 2*(halfLen + band_r), corner radius - halfWid + band_r. Drawn outer-to-inner so each band overpaints the next. - """ - halfWid_in = (dims_mm["width"] / 2) / 25.4 - halfLen_in = (dims_mm["length"] / 2) / 25.4 - max_dist = config["max_radius"] - - shortSpan_in = 2 * (halfWid_in + max_dist) - longSpan_in = 2 * (halfLen_in + max_dist) - - W = int(round(shortSpan_in * pixels_per_inch)) - H = int(round(longSpan_in * pixels_per_inch)) - cx, cy = W / 2, H / 2 - - img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - a = int(alpha * 255) - - for band_r, color_key in reversed(config["bands"]): - half_w_px = (halfWid_in + band_r) * pixels_per_inch - half_h_px = (halfLen_in + band_r) * pixels_per_inch - r, g, b = RANGE_COLORS[color_key] - draw.rounded_rectangle( - [(cx - half_w_px, cy - half_h_px), - (cx + half_w_px, cy + half_h_px)], - radius=half_w_px, # = half of the short side → semicircle caps - fill=(r, g, b, a), - ) - - out = OUT_DIR / f"range_fig_leader_{base_size}_stadium.png" - img.save(out) - return out - - -def generate_filled_disk(name: str, color_rgb: tuple, size: int = 512, - alpha: float = 0.2) -> Path: - """Generate a simple filled disk PNG (used for Maximum Move overlay).""" - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - cx = cy = size / 2 - r = size / 2 - a = int(alpha * 255) - draw.ellipse([(cx - r, cy - r), (cx + r, cy + r)], - fill=(color_rgb[0], color_rgb[1], color_rgb[2], a)) - out = OUT_DIR / f"{name}.png" - img.save(out) - return out - - -def generate_filled_square(name: str, color_rgb: tuple, size: int = 256, - alpha: float = 0.3) -> Path: - """Generate a filled square PNG (used for Deployment Boundary cells).""" - img = Image.new("RGBA", (size, size), - (color_rgb[0], color_rgb[1], color_rgb[2], int(alpha * 255))) - out = OUT_DIR / f"{name}.png" - img.save(out) - return out - - -def generate_silhouette_top_disc(size: int = 512) -> Path: - """Hologram-style top cap for the Mac silhouette cylinder. - - Radial gradient: bright green-cyan center fades to transparent at the - rim. Combined with the vector wireframe cylinder this gives the - Star Wars hologram briefing look (Leia "Help me Obi-Wan" projection). - """ - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - pixels = img.load() - cx, cy = size / 2, size / 2 - max_r = size / 2 - - r_center, g_center, b_center = 102, 255, 178 # bright hologram cyan-green - a_center = 0.85 - - for y in range(size): - for x in range(size): - dx, dy = x - cx, y - cy - dist = math.sqrt(dx * dx + dy * dy) - r_norm = dist / max_r - if r_norm > 1.0: - continue - # Smooth radial falloff (1-r squared) for a soft hologram glow - fade = (1.0 - r_norm) ** 1.5 - alpha = int(a_center * fade * 255) - pixels[x, y] = (r_center, g_center, b_center, alpha) - - out = OUT_DIR / "silhouette_top.png" - img.save(out) - return out - - -def main(): - p = generate_cohesion_halo() - print(f"Generated {p} ({p.stat().st_size} bytes)") - - # Fig leaders (round bases): one PNG per base size, bands shifted by base radius. - fig_cfg = RANGE_BAND_CONFIGS["fig_leader"] - for base_size, br in FIG_BASE_RADIUS_IN.items(): - out = generate_range_band_png(f"fig_leader_{base_size}", - _with_base_offset(fig_cfg, br)) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Oblong bases: stadium-shape PNGs (rectangular footprint + rounded caps). - for base_size, dims in OBLONG_DIMENSIONS_MM.items(): - out = generate_range_band_stadium_png(base_size, dims, fig_cfg) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Multi-band tokens: bake in their base radius for alignment. - for name, br in TOKEN_BASE_RADIUS_IN.items(): - cfg = RANGE_BAND_CONFIGS[name] - out = generate_range_band_png(name, _with_base_offset(cfg, br)) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Single-band tokens: no offset adjustment needed (band sits at outer edge). - for name in ("smokeToken", "token", "poi"): - out = generate_range_band_png(name, RANGE_BAND_CONFIGS[name]) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Maximum Move: cyan filled disk (#55CCFF) - p = generate_filled_disk("max_move_cyan", (85, 204, 255), alpha=0.2) - print(f"Generated {p} ({p.stat().st_size} bytes)") - - # Deployment Boundary: red and blue filled squares (alpha 0.5 = match bundle) - p = generate_filled_square("deployment_red", (255, 0, 0), alpha=0.3) - print(f"Generated {p} ({p.stat().st_size} bytes)") - p = generate_filled_square("deployment_blue", (0, 0, 255), alpha=0.3) - print(f"Generated {p} ({p.stat().st_size} bytes)") - - # Silhouette top cap: hologram-style green/cyan radial gradient. - p = generate_silhouette_top_disc() - print(f"Generated {p} ({p.stat().st_size} bytes)") - - -if __name__ == "__main__": - main() diff --git a/mac-patcher/inspect_cohesion.py b/mac-patcher/inspect_cohesion.py deleted file mode 100644 index e26becbe..00000000 --- a/mac-patcher/inspect_cohesion.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Inspect Cohesion bundle structure: list objects, materials, shaders, and the m_Shader PPtr.""" -import os -import sys -import UnityPy - -BUNDLE = os.path.expanduser( - "~/Library/Tabletop Simulator/Mods/Assetbundles/" - "httpssteamusercontentaakamaihdnetugc2482129948496305632EBBE2560D4336E6C96317EDDA787E225CF0E5B48.unity3d" -) - -env = UnityPy.load(BUNDLE) - -print("=" * 80) -print("BUNDLE METADATA") -print("=" * 80) -for f in env.files.values(): - print(f" file: {type(f).__name__}") - if hasattr(f, "unity_version"): - print(f" unity_version: {f.unity_version}") - if hasattr(f, "target_platform"): - print(f" target_platform: {f.target_platform}") - if hasattr(f, "externals"): - print(f" externals (cross-bundle refs):") - for ext in getattr(f, "externals", []): - print(f" - {ext}") - -print() -print("=" * 80) -print("ALL OBJECTS") -print("=" * 80) -for obj in env.objects: - path_id = getattr(obj, "path_id", "?") - type_name = obj.type.name - try: - data = obj.read() - name = getattr(data, "m_Name", "") or "" - except Exception as e: - name = f"" - print(f" PathID={path_id:<6} type={type_name:<15} name={name!r}") - -print() -print("=" * 80) -print("MATERIAL DETAILS (looking for Cohesion_27mm)") -print("=" * 80) -for obj in env.objects: - if obj.type.name != "Material": - continue - data = obj.read() - print(f"\nMaterial PathID={obj.path_id} name={data.m_Name!r}") - sref = data.m_Shader - print(f" m_Shader PPtr:") - print(f" file_id = {getattr(sref, 'file_id', '?')} " - f"(0 = internal to this bundle, !=0 = external file)") - print(f" path_id = {getattr(sref, 'path_id', '?')}") - # Try to resolve - try: - sobj = sref.deref() - if sobj: - sd = sobj.read() - sname = ( - getattr(getattr(sd, "m_ParsedForm", None), "m_Name", None) - or getattr(sd, "m_Name", None) - ) - print(f" resolved -> Shader name = {sname!r}") - else: - print(f" resolved -> None (external or missing)") - except Exception as e: - print(f" resolve error: {e!r}") - -print() -print("=" * 80) -print("SHADER DETAILS") -print("=" * 80) -for obj in env.objects: - if obj.type.name != "Shader": - continue - data = obj.read() - sname = ( - getattr(getattr(data, "m_ParsedForm", None), "m_Name", None) - or getattr(data, "m_Name", None) - ) - print(f" PathID={obj.path_id} Shader name={sname!r}") diff --git a/mac-patcher/inspect_externals.py b/mac-patcher/inspect_externals.py deleted file mode 100644 index c9307a21..00000000 --- a/mac-patcher/inspect_externals.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -"""For each bundle: dump its externals (referenced external files) and list materials with external m_Shader PPtr.""" -import os -import sys -import UnityPy - -BUNDLE_DIR = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles/") - -bundles = sorted(f for f in os.listdir(BUNDLE_DIR) if f.endswith(".unity3d")) - -# We are most interested in bundles whose materials use external shaders (file_id != 0) -print("=" * 90) -print("BUNDLES WITH EXTERNAL m_Shader REFERENCES") -print("=" * 90) - -for fname in bundles: - path = os.path.join(BUNDLE_DIR, fname) - short_id = fname.replace("httpssteamusercontentaakamaihdnetugc", "")[:20] - env = UnityPy.load(path) - - external_mats = [] - for obj in env.objects: - if obj.type.name != "Material": - continue - try: - data = obj.read() - sref = data.m_Shader - fid = getattr(sref, "file_id", None) - pid = getattr(sref, "path_id", None) - if fid and fid != 0: - external_mats.append((data.m_Name, fid, pid)) - except Exception: - pass - - if not external_mats: - continue - - print(f"\n{short_id}:") - # Dump externals table - for f in env.files.values(): - if hasattr(f, "externals"): - print(f" externals table:") - for i, ext in enumerate(getattr(f, "externals", [])): - # The externals entry usually has a 'path' and 'guid' - ext_path = getattr(ext, "path", "?") - ext_guid = getattr(ext, "guid", "?") - # index i+1 because file_id is 1-based for externals (file_id=0 means self) - print(f" file_id={i+1} path={ext_path!r} guid={ext_guid}") - print(f" materials with external m_Shader:") - for name, fid, pid in external_mats: - print(f" material={name!r} m_Shader=(file_id={fid}, path_id={pid})") diff --git a/mac-patcher/materials-reference.md b/mac-patcher/materials-reference.md deleted file mode 100644 index eabaae36..00000000 --- a/mac-patcher/materials-reference.md +++ /dev/null @@ -1,170 +0,0 @@ -# Materials & Prefabs Reference — SWL TTS Projector Overlays - -Extraction directe des `.mat` et `.prefab` Unity (YAML) dans `UnityProject-U6/Assets/Projectors/`. Source de vérité pour reproduire le rendu en vector lines. - -Notation : couleurs en hex + alpha. Champs `Floats` clés du shader projector (paramètres procéduraux des anneaux). - -## Cohesion - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `Cohesion_27mm` | `_ARC_OFF _MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=4.479488, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.37)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #000000 (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_50mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=5.470629, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_70mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=6.850394, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_27mm` | `_ARC_OFF _MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=3.531496, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.37)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #000000 (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_50mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=3.984252, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_70mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=4.3779526, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `cohesion_27mm` | 1 | size=4.547244, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | ebe2e870a59f… | -| `cohesion_50mm` | 1 | size=5.669291, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 406845c49cf4… | -| `cohesion_70mm` | 1 | size=6.850394, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 3a57b0b27e32… | -| `halfCohesion_27mm` | 1 | size=3.531496, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 0e1c90ce1ef6… | -| `halfCohesion_50mm` | 1 | size=3.984252, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 2e05a24ab516… | -| `halfCohesion_70mm` | 1 | size=4.3779526, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 7cee44cc736f… | - -## Range - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `ProjectorMaterial_100mm` | `_ARC_ON _MAXRANGE_RANGEFIVE _RANGE_FIVE` | `_BaseSize`=100, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.0183, `_TwoTuner`=0.0062, `_ThreeTuner`=0.0245, `_FourTuner`=0.0123, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_100mm_oblong` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=47.4, `_ProjectorRadius`=30, `_BandSize`=0.34, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=-0.022, `_TwoTuner`=-0.0421, `_ThreeTuner`=-0.0324, `_FourTuner`=-0.0525, `_FiveTuner`=0.073, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_100mm_snail` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=47.4, `_ProjectorRadius`=30, `_BandSize`=0.34, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=-0.0216, `_TwoTuner`=-0.0421, `_ThreeTuner`=-0.0324, `_FourTuner`=-0.0525, `_FiveTuner`=0.073, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_120mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=120, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.022, `_TwoTuner`=0.0074, `_ThreeTuner`=0.0292, `_FourTuner`=0.0146, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_150mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=150, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.027, `_TwoTuner`=0.009, `_ThreeTuner`=0.0358, `_FourTuner`=0.018, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_18mm_smokeToken` | `_ARC_OFF _MAXRANGE_RANGEONE` | `_BaseSize`=18.8, `_ProjectorRadius`=6, `_BandSize`=-0.11, `_BandContrast`=0.46, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #3A3145 (α=0.59)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_25mm_token` | `_ARC_OFF _MAXRANGE_RANGEONE` | `_BaseSize`=27, `_ProjectorRadius`=2.97, `_BandSize`=-0.45, `_BandContrast`=3, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_25mm_tokenRangeTwo` | `_ARC_OFF _MAXRANGE_RANGETWO` | `_BaseSize`=28.12, `_ProjectorRadius`=14.9, `_BandSize`=0.04, `_BandContrast`=3, `_MaxRange`=1, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0062, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_27mm` | `_ARC_OFF _MAXRANGE_RANGEFIVE` | `_BaseSize`=27, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=0, `_RangeSize`=-76.5, `_GradScaler`=0.0034, `_OneTuner`=0.0053, `_TwoTuner`=0.0019, `_ThreeTuner`=0.0072, `_FourTuner`=0.0037, `_FiveTuner`=0, `_InfinityTuner`=0.0034 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_50mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=50, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_GradScaler`=0, `_OneTuner`=0.0098, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0127, `_FourTuner`=0.0063, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_50mm_bombCart` | `_ARC_OFF _MAXRANGE_RANGETWO` | `_BaseSize`=50, `_ProjectorRadius`=12, `_BandSize`=0.01, `_BandContrast`=3, `_MaxRange`=1, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_70mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=70, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=2.87, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.0132, `_TwoTuner`=0.0045, `_ThreeTuner`=0.0175, `_FourTuner`=0.009, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.35)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `Projector_BoardGuide` | `m_LightmapFlags: 4` | — | `_CameraFadeParams` transparent
`_Color` #FFFFFF (α=1.00)
`_ColorAddSubDiff` transparent
`_EmissionColor` #000000 (α=1.00)
`_SoftParticleFadeParams` transparent
`_TintColor` #7F7F7F (α=0.50) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `projector_100mm` | 1 | size=31.9685, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 4b37ff3d68c5… | -| `projector_100mm_oblong` | 1 | size=33.4, far=45 | pos=(0.0, -1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 6c0556b105e8… | -| `projector_120mm` | 1 | size=32.3622, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | ab767c454926… | -| `projector_150mm` | 1 | size=32.95276, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 3a33cb678c17… | -| `projector_200mm_oblong` | 1 | size=33.4, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0)
pos=(-0.0, -0.26, -0.0) rot=(0.0, 0.0, 90.0) | cccc683b4b18… | -| `projector_27mm` | 1 | size=30.5314, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | b1c5ef617632… | -| `projector_50mm` | 1 | size=30.984253, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 20ce39a8e0e1… | -| `projector_70mm` | 1 | size=31.37795, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 977480df799a… | -| `projector_POIGuide` | 1 | size=36, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | a0fb1f64d52e… | -| `projector_bomb_cart` | 1 | size=12.98425, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 417ae88dafb1… | -| `projector_smokeToken` | 1 | size=6.3700786, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 3ef1e0f12697… | -| `projector_token` | 1 | size=7.06299, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 94ad9a7a030f… | -| `projector_tokenRangeTwo` | 1 | size=12.6, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 0905fd93d0b7… | - -## Deployment - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `Projector_Deployment_Blue` | `m_LightmapFlags: 4` | — | `_Color` #0000FF (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Deployment_Blue_Round` | `m_LightmapFlags: 4` | — | `_Color` #0000FF (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Deployment_Red` | `m_LightmapFlags: 4` | — | `_Color` #FF0000 (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Deployment_Red_Round` | `m_LightmapFlags: 4` | — | `_Color` #FF0000 (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Score_Blue` | `m_LightmapFlags: 4` | — | `_Color` #0000FF (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Score_Red` | `m_LightmapFlags: 4` | — | `_Color` #FF0000 (α=0.50)
`_EmissionColor` #000000 (α=1.00) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `projector_blue_score` | 1 | size=6, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | fe66dad2ac93… | -| `projector_blue_spawn` | 1 | size=3, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 23c778da5a02… | -| `projector_blue_spawn_L` | 2 | size=1.5, far=25
size=3, far=25 | pos=(10.65, 10.71, -19.76) rot=(90.0, 0.0, 0.0)
pos=(1.5, 1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(0.0, -1.5000002, 0.0) rot=(0.0, 0.0, 90.0) | 23c778da5a02…
23c778da5a02… | -| `projector_blue_spawn_corner` | 1 | size=1.5, far=25 | pos=(10.65, 10.71, -19.76) rot=(90.0, 0.0, 0.0)
pos=(-1.5, -1.5, 0.0) rot=(0.0, 0.0, 90.0) | 23c778da5a02… | -| `projector_blue_spawn_half` | 1 | size=3, far=25 | pos=(0.0, -1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(10.65, 10.71, -19.76) rot=(90.0, 0.0, 0.0) | 23c778da5a02… | -| `projector_blue_spawn_round2` | 1 | size=12, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 34786d447192… | -| `projector_red_score` | 1 | size=6, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 90fdc53ff83c… | -| `projector_red_spawn` | 1 | size=3, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 93e068805588… | -| `projector_red_spawn_L` | 2 | size=3, far=25
size=1.5, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0)
pos=(0.0, 1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(-1.5, -1.5, -0.0) rot=(0.0, 0.0, 90.0) | 93e068805588…
93e068805588… | -| `projector_red_spawn_corner` | 1 | size=1.5, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0)
pos=(1.5, 1.5, -0.0) rot=(0.0, 0.0, 90.0) | 93e068805588… | -| `projector_red_spawn_half` | 1 | size=3, far=25 | pos=(0.0, 1.5, -0.0) rot=(0.0, 0.0, 90.0)
pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 93e068805588… | -| `projector_red_spawn_round2` | 1 | size=12, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 54824e17d05f… | - -## Movement - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `ProjectorMaterial_100mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=8.622047, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_100mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=10.590551, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_100mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=12.559055, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_120mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=9.803149, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_120mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=11.771653, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_120mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=13.740157, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_150mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=11.574803, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_150mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=13.543307, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_150mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=15.511811, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed1_double` | `_MAXRANGE_RANGEINFINITY _MOVES_DOUBLE` | `_BaseSize`=27, `_ProjectorRadius`=8.45, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=4.547244, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed2_double` | `_MAXRANGE_RANGEINFINITY _MOVES_DOUBLE` | `_BaseSize`=27, `_ProjectorRadius`=12.42748, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=6.515748, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed3_double` | `_MAXRANGE_RANGEINFINITY _MOVES_DOUBLE` | `_BaseSize`=27, `_ProjectorRadius`=16.42748, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=8.484252, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_50mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=5.6692915, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_50mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=7.6377954, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_50mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=9.606299, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_70mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=6.850394, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_70mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=8.818897, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_70mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=10.787401, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_oblong_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=13.051181, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_oblong_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=15.019685, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_oblong_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=16.98819, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_snail_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=14.527559, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_snail_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=16.496063, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_snail_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=18.464567, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `movement_100mm_speed1_single` | 1 | size=8.622047, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 406c1ddb0995… | -| `movement_100mm_speed2_single` | 1 | size=10.590551, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 0aa471d50563… | -| `movement_100mm_speed3_single` | 1 | size=12.559055, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 8fe5008dea10… | -| `movement_120mm_speed1_single` | 1 | size=9.803149, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 21bc9fa53646… | -| `movement_120mm_speed2_single` | 1 | size=11.771653, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 6d02674694a9… | -| `movement_120mm_speed3_single` | 1 | size=13.740157, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 895a4d7a1f02… | -| `movement_150mm_speed1_single` | 1 | size=11.574803, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | fef40c5dadca… | -| `movement_150mm_speed2_single` | 1 | size=13.543307, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | c9d6b007a730… | -| `movement_150mm_speed3_single` | 1 | size=15.511811, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | d08a6fca8013… | -| `movement_27mm_speed1_double` | 1 | size=8.45, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 7037d7245d82… | -| `movement_27mm_speed1_single` | 1 | size=4.547244, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 61da0f6e009e… | -| `movement_27mm_speed2_double` | 1 | size=12.42748, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 362084914234… | -| `movement_27mm_speed2_single` | 1 | size=6.515748, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 6de57d74ffda… | -| `movement_27mm_speed3_double` | 1 | size=16.42748, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | a0dce2ad51d5… | -| `movement_27mm_speed3_single` | 1 | size=8.484252, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | b65b690fde07… | -| `movement_50mm_speed1_single` | 1 | size=5.6692915, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 38f1f5f6a60c… | -| `movement_50mm_speed2_single` | 1 | size=7.6377954, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 3d2f279db06b… | -| `movement_50mm_speed3_single` | 1 | size=9.606299, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | ca54633a2d92… | -| `movement_70mm_speed1_single` | 1 | size=6.850394, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 3d2faeaaaaff… | -| `movement_70mm_speed2_single` | 1 | size=8.818897, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 948d0799a0c3… | -| `movement_70mm_speed3_single` | 1 | size=10.787401, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | a986001c0bc6… | -| `movement_oblong_speed1_single` | 1 | size=13.051181, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | b9540fc3dad6… | -| `movement_oblong_speed2_single` | 1 | size=15.019685, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 672e50dcd47c… | -| `movement_oblong_speed3_single` | 1 | size=16.98819, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | a2468c9fe07d… | -| `movement_snail_speed1_single` | 1 | size=14.527559, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | b7c44d9516fd… | -| `movement_snail_speed2_single` | 1 | size=16.496063, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | d27d5e30c83c… | -| `movement_snail_speed3_single` | 1 | size=18.464567, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | ecdda5f21c4c… | - -## Notes globales - -- Cohesion : double set `Materials/cohesion_*` et `_Revamp/halfCohesion_*`. Probable : `_Revamp/` est la version actuellement utilisée (le bundle live s'appelle `halfcohesion_27mm.unity3d` cf shader-inventory). -- Range : shader procédural avec jusqu'à 5 bandes (`_RangeOne` à `_RangeFive`) + `_RangeInfinity`. Le keyword `_MAXRANGE_RANGEFIVE` ou `_MAXRANGE_RANGEFOUR` détermine la dernière bande visible. -- Movement : un seul Projector par prefab, paramétré par `_BaseSize` + speed. Le `_Arc` doit contrôler la portion d'arc visible. -- Deployment : 6 materials uniques réutilisés par 12 prefabs (rotations différentes pour matérialiser corner/half/L/round/score). \ No newline at end of file diff --git a/mac-patcher/patch_cohesion_rename.py b/mac-patcher/patch_cohesion_rename.py deleted file mode 100644 index 065a14a9..00000000 --- a/mac-patcher/patch_cohesion_rename.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -""" -PATCH ATTEMPT #1 — Rename embedded Shader m_Name from -'BucketheadBits/Projector/Cohesion' to 'Standard'. - -Theory: at AssetBundle.LoadAsset, Unity may resolve the shader by name via -Shader.Find() before falling back to the embedded bytecode. If so, TTS picks -up its own built-in Standard shader and renders the Cohesion projector with -it, ignoring the broken embedded shader. - -Writes to .patched.unity3d alongside the original. Does NOT touch -the live bundle in ~/Library/Tabletop Simulator/Mods/Assetbundles/ yet. -""" -import os -import sys -import shutil -import UnityPy - -BUNDLE_DIR = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles/") -BUNDLE_NAME = "httpssteamusercontentaakamaihdnetugc2482129948496305632EBBE2560D4336E6C96317EDDA787E225CF0E5B48.unity3d" -SRC = os.path.join(BUNDLE_DIR, BUNDLE_NAME) -DST = os.path.join("/tmp", "cohesion.patched.unity3d") - -print(f"Loading: {SRC}") -env = UnityPy.load(SRC) - -modified = False -for obj in env.objects: - if obj.type.name != "Shader": - continue - data = obj.read() - current = getattr(getattr(data, "m_ParsedForm", None), "m_Name", None) or getattr(data, "m_Name", None) - print(f" Found Shader PathID={obj.path_id} current m_Name={current!r}") - if current != "BucketheadBits/Projector/Cohesion": - print(" -> Not our target, skipping.") - continue - - # Modify via typetree (safest cross-version path with UnityPy) - tree = obj.read_typetree() - # m_ParsedForm.m_Name is the canonical name for parsed shaders. - # m_Name may also exist for the asset itself; we try both. - if "m_ParsedForm" in tree and "m_Name" in tree["m_ParsedForm"]: - old = tree["m_ParsedForm"]["m_Name"] - tree["m_ParsedForm"]["m_Name"] = "Standard" - print(f" -> renamed m_ParsedForm.m_Name {old!r} -> 'Standard'") - modified = True - if "m_Name" in tree and tree.get("m_Name"): - old = tree["m_Name"] - tree["m_Name"] = "Standard" - print(f" -> renamed m_Name {old!r} -> 'Standard'") - modified = True - - obj.save_typetree(tree) - -if not modified: - print("ERROR: no shader matched. Aborting.") - sys.exit(1) - -# Write out the patched bundle -print(f"\nSaving patched bundle to: {DST}") -with open(DST, "wb") as f: - f.write(env.file.save(packer="lz4")) - -print(f" size: {os.path.getsize(DST)} bytes (original was {os.path.getsize(SRC)})") - -# Verify roundtrip -print("\nVerifying patched bundle is re-loadable...") -env2 = UnityPy.load(DST) -for obj in env2.objects: - if obj.type.name == "Shader": - d = obj.read() - n = getattr(getattr(d, "m_ParsedForm", None), "m_Name", None) or getattr(d, "m_Name", None) - print(f" After patch: Shader PathID={obj.path_id} m_Name={n!r}") - elif obj.type.name == "Material": - d = obj.read() - sref = d.m_Shader - print(f" Material {d.m_Name!r} m_Shader=(file_id={sref.file_id}, path_id={sref.path_id})") - -print("\nDone. Patched file ready at:", DST) -print("Run this command to replace the live bundle (only when TTS is closed):") -print(f" cp {DST!r} {SRC!r}") diff --git a/mac-patcher/patch_save_for_mac.py b/mac-patcher/patch_save_for_mac.py deleted file mode 100644 index 24bd5e22..00000000 --- a/mac-patcher/patch_save_for_mac.py +++ /dev/null @@ -1,2171 +0,0 @@ -#!/usr/bin/env python3 -""" -Patch a TTS save JSON (SWL mod beta) to add Mac Cohesion fallback. - -Two-part patch: -1. Append manager + event handlers to the Global LuaScript. -2. Replace the inline Cohesion block in Unit_Leader (99f1c8) and Order_Token - (a57c41) with delegated stubs that call the Global manager via Global.call. - -The native hotkey "Show Cohesion On Hovered Model" and the Order Token -"COHESION" button automatically use the patched code afterwards - they -spawn vector-lines rings instead of legacy Custom_AssetBundle Projectors. - -Usage: - python3 patch_save_for_mac.py -""" -import json -import re -import socket -import sys -from pathlib import Path - -# ------------------------------------------------------------ -# 1) Code appended to Global LuaScript (manager + event handlers) -# ------------------------------------------------------------ -GLOBAL_PATCH_LUA = r""" - --- ============================================ --- MAC COHESION FALLBACK (auto-injected for TTS U6 magenta bug) --- Manager Global + event handlers + vector-lines rendering. --- Called by Unit_Leader / Order_Token via Global.call from their patched --- Cohesion functions. --- ============================================ - --- Capture the ORIGINAL Global functions loaded by !/RangeRulers and --- !/Cohesion BEFORE we overwrite them below. We also capture the inner --- spawn/clear pair because the toggle wrapper resolves them by NAME at --- call time - without aliasing those too, the original toggle would route --- back into our Mac versions, defeating Windows mode entirely. -spawnRangeRulerOriginalGlobal = spawnRangeRuler -clearRangeRulersOriginalGlobal = clearRangeRulers -spawnCohesionRulerOriginalGlobal = spawnCohesionRuler -clearCohesionRulerOriginalGlobal = clearCohesionRuler - -activeOverlays = activeOverlays or {} -hiddenWhilePickedUp = hiddenWhilePickedUp or {} - -local ASSETS_BASE = "https://raw.githubusercontent.com/ironsquadronfr-hub/tts/mac-projector-fallback/mod/data/mac-fallback-assets/" -local COHESION_HALO_URL = ASSETS_BASE .. "cohesion_halo.png" -local MAX_MOVE_CYAN_URL = ASSETS_BASE .. "max_move_cyan.png" -local DEPLOYMENT_RED_URL = ASSETS_BASE .. "deployment_red.png" -local DEPLOYMENT_BLUE_URL = ASSETS_BASE .. "deployment_blue.png" -local RANGE_DECAL_URLS = { - smokeToken = ASSETS_BASE .. "range_smokeToken.png", - token = ASSETS_BASE .. "range_token.png", - tokenRangeTwo = ASSETS_BASE .. "range_tokenRangeTwo.png", - poi = ASSETS_BASE .. "range_poi.png", - bombCart = ASSETS_BASE .. "range_bombCart.png", - -- Fig leaders: per-base-size PNGs (bands pre-offset by the base radius so - -- the decal bands line up with the vector-line rings). - fig_leader = { - small = ASSETS_BASE .. "range_fig_leader_small.png", - medium = ASSETS_BASE .. "range_fig_leader_medium.png", - large = ASSETS_BASE .. "range_fig_leader_large.png", - huge = ASSETS_BASE .. "range_fig_leader_huge.png", - laat = ASSETS_BASE .. "range_fig_leader_laat.png", - epic = ASSETS_BASE .. "range_fig_leader_epic.png", - long = ASSETS_BASE .. "range_fig_leader_long_stadium.png", - snail = ASSETS_BASE .. "range_fig_leader_snail_stadium.png", - }, -} -local RANGE_MAX_RADIUS = { - fig_leader = 30, -- R5 max - -- SWL: Range 1 = 6", Range 2 = 12", Range 0.5 = 3". - smokeToken = 6, - token = 6, - tokenRangeTwo = 12, - poi = 3, - bombCart = 12, -} - --- Range band colors (RGBA) extracted from BB_RangeProjector material defaults --- + demi-portee blanche pour SWL Range 0.5 (3 inches) -local RANGE_COLORS = { - half = {1.0, 1.0, 1.0, 0.6}, -- 0.5 = 3" white - one = {1.0, 0.764, 0.0, 0.7}, -- R1 yellow - two = {1.0, 0.459, 0.0, 0.7}, -- R2 orange - three = {1.0, 0.079, 0.0, 0.7}, -- R3 red - four = {0.764, 0.0, 0.259, 0.7}, -- R4 magenta - five = {0.528, 0.0, 0.443, 0.7}, -- R5 violet -} - --- Per-rangeKey configurations: list of {radius_inches, color_name} -local RANGE_CONFIGS = { - -- Fig leaders: half + 4 bands at SWL standard ranges 3/6/12/18/24" - small = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - medium = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - large = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - huge = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - laat = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - epic = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - long = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - snail = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - -- Token rangeKeys (1 to 2 bands depending on use). SWL ranges in inches: - -- Range 1 = 6", Range 2 = 12", Range 0.5 (poi) = 3". - smokeToken = {{r=6,c="one"}}, - token = {{r=6,c="one"}}, - tokenRangeTwo = {{r=6,c="one"},{r=12,c="two"}}, - poi = {{r=3,c="one"}}, - bombCart = {{r=6,c="one"},{r=12,c="two"}}, -} - --- Base footprint dimensions in mm. width <= length; equal means round. --- Used to drive round vs stadium overlay shape. -local BASE_DIMENSIONS = { - small = {width = 27, length = 27}, - medium = {width = 50, length = 50}, - large = {width = 70, length = 70}, - huge = {width = 100, length = 100}, - laat = {width = 120, length = 120}, - epic = {width = 150, length = 150}, - long = {width = 100, length = 175}, -- oblong - snail = {width = 100, length = 200}, -- oblong -} - --- Base radius (inches) for round overlays. For oblong bases we use a --- stadium shape instead (see macBuildStadium), so the radius here is the --- half-width that drives the cap arcs. -local FIG_BASE_RADIUS_IN = {} -for k, d in pairs(BASE_DIMENSIONS) do - FIG_BASE_RADIUS_IN[k] = (d.width / 2) / 25.4 -end - -local function macIsOblong(baseSize) - local d = BASE_DIMENSIONS[baseSize] - return d ~= nil and d.length > d.width -end - --- Returns the stadium half-dimensions (inches) and yaw for an oblong base, --- or nil if the fig's base is round. Callers should branch on the return. -local function macOblongDims(fig, baseSize) - if not macIsOblong(baseSize) then return nil end - local d = BASE_DIMENSIONS[baseSize] - return { - halfLen = (d.length / 2) / 25.4, - halfWid = (d.width / 2) / 25.4, - rotY = fig.getRotation().y, - } -end - --- Resolve baseSize for a fig. The mod stores it as a script-local Var on the --- fig itself (set at spawn from the unit's container). unitData.baseSize is --- only populated on the Unit Leader, not on minis/vehicles - so getVar is --- the canonical read, with unitData as fallback for safety. -local function macGetBaseSize(fig) - if not fig then return nil end - local ok, bs = pcall(function() return fig.getVar("baseSize") end) - if ok and bs then return bs end - local data = fig.getTable("unitData") - if data and data.baseSize then return data.baseSize end - return nil -end - -local function macFigBaseRadius(fig) - if not fig then return 0.5315 end - local bs = macGetBaseSize(fig) - return (bs and FIG_BASE_RADIUS_IN[bs]) or 0.5315 -end - --- Token base radii (inches) - measured from token edge for range bands -local TOKEN_BASE_RADIUS = { - smokeToken = 18.8 / 2 / 25.4, -- 0.370" - token = 25.1 / 2 / 25.4, -- 0.494" - tokenRangeTwo = 25.1 / 2 / 25.4, -- 0.494" - poi = 50.8 / 2 / 25.4, -- 1.000" - bombCart = 50.0 / 2 / 25.4, -- 0.984" -} - --- Hotkey-driven range on tokens renders fig-leader-style bands using the --- closest equivalent base size (so the visual matches what a unit of that --- footprint would see). Token R button keeps the single ring (token-spec). -local TOKEN_TO_BASESIZE = { - smokeToken = "small", - token = "small", - tokenRangeTwo = "small", - poi = "medium", - bombCart = "medium", -} - --- Maximum Move radii (inches), indexed by baseSize then speed 1..3 --- Source: ProjectorRadius from BB_MovementProjector materials (single variants) -local MAX_MOVE_RADIUS = { - small = { 4.547244, 6.515748, 8.484252}, -- 27mm - medium = { 5.669292, 7.637795, 9.606299}, -- 50mm - large = { 6.850394, 8.818897, 10.787401}, -- 70mm - huge = { 8.622047, 10.590551, 12.559055}, -- 100mm - laat = { 9.803149, 11.771653, 13.740157}, -- 120mm - epic = {11.574803, 13.543307, 15.511811}, -- 150mm - long = {13.051181, 15.019685, 16.988190}, -- oblong - snail = {14.527559, 16.496063, 18.464567}, -- snail -} - --- Deployment cell dimensions (width X, depth Z, inches). --- All cells live on a 6"x6" grid; sub-cells are halves/quarters of that cell. -local DEPLOYMENT_CELL_SIZES = { - r = {6, 6}, b = {6, 6}, -- full cell - rl = {6, 6}, bl = {6, 6}, -- large variant (also full cell) - rh = {6, 3}, bh = {6, 3}, -- horizontal half (3" deep) - rs = {3, 6}, bs = {3, 6}, -- vertical half (3" wide), x-offset +1.5 - rss = {3, 6}, bss = {3, 6}, -- vertical half (3" wide), x-offset -1.5 - rc = {3, 3}, bc = {3, 3}, -- corner quarter - rcc = {3, 3}, bcc = {3, 3}, -- opposite corner quarter -} - -local function macRayGroundY(x, z, offset) - local hits = Physics.cast({ - origin = {x, 30, z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 50, - }) - for _, h in ipairs(hits) do - return h.point.y + (offset or 0.05) - end - return offset or 0.05 -end - -local function macBuildRect(cx, cz, hw, hd, color, thickness) - local segPerSide = 8 - local pts = {} - local corners = { - {cx - hw, cz + hd}, {cx + hw, cz + hd}, - {cx + hw, cz - hd}, {cx - hw, cz - hd}, - } - for i = 1, 4 do - local a = corners[i] - local b = corners[i % 4 + 1] - for s = 0, segPerSide - 1 do - local t = s / segPerSide - local x = a[1] + (b[1] - a[1]) * t - local z = a[2] + (b[2] - a[2]) * t - table.insert(pts, {x, macRayGroundY(x, z), z}) - end - end - local c1 = corners[1] - table.insert(pts, {c1[1], macRayGroundY(c1[1], c1[2]), c1[2]}) - return {points = pts, color = color, thickness = thickness or 0.06} -end - -local function macBuildRing(centerPos, radius, color, thickness, ignoreObj) - local nSeg = 64 - local pts = {} - for i = 0, nSeg do - local a = (i / nSeg) * 2 * math.pi - local x = centerPos.x + radius * math.cos(a) - local z = centerPos.z + radius * math.sin(a) - local hits = Physics.cast({ - origin = {x, centerPos.y + 10, z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local y = centerPos.y + 0.05 - for _, h in ipairs(hits) do - if h.hit_object ~= ignoreObj then - y = h.point.y + 0.05 - break - end - end - table.insert(pts, {x, y, z}) - end - return {points = pts, color = color, thickness = thickness or 0.05} -end - --- Stadium = rectangle with two semicircular caps on the long-axis ends. --- halfLen, halfWid in inches (halfLen >= halfWid). dist = expansion outwards --- from the base edge in inches (so the overlay sits at dist outside the base). --- rotYdeg rotates the stadium around Y to match the fig's orientation; the --- long axis is local Z (front-to-back of the model). -local function macBuildStadium(centerPos, halfLen, halfWid, rotYdeg, dist, color, thickness, ignoreObj) - local rotRad = math.rad(rotYdeg or 0) - local cosR, sinR = math.cos(rotRad), math.sin(rotRad) - - local capCenter = halfLen - halfWid -- distance from center to cap arc center, along long axis - local capR = halfWid + dist -- cap arc radius (= straight-side offset from long axis) - - local nSegArc = 32 -- segments per semicircle cap - local pts = {} - - local function addLocal(lx, lz) - local wx = centerPos.x + lx * cosR + lz * sinR - local wz = centerPos.z - lx * sinR + lz * cosR - local hits = Physics.cast({ - origin = {wx, centerPos.y + 10, wz}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local y = centerPos.y + 0.05 - for _, h in ipairs(hits) do - if h.hit_object ~= ignoreObj then - y = h.point.y + 0.05 - break - end - end - table.insert(pts, {wx, y, wz}) - end - - -- Top cap: angle 0 to pi (right edge -> top -> left edge), centered at z=+capCenter - for i = 0, nSegArc do - local a = (i / nSegArc) * math.pi - addLocal(capR * math.cos(a), capCenter + capR * math.sin(a)) - end - -- The straight left side is implicit: the polyline jumps from - -- (-capR, +capCenter) directly to (-capR, -capCenter) - TTS draws a - -- straight segment between consecutive points. - -- Bottom cap: angle pi to 2pi, centered at z=-capCenter - for i = 0, nSegArc do - local a = math.pi + (i / nSegArc) * math.pi - addLocal(capR * math.cos(a), -capCenter + capR * math.sin(a)) - end - -- Close the loop back to the top-cap start point. - addLocal(capR, capCenter) - - return {points = pts, color = color, thickness = thickness or 0.05} -end - -local macBuilders = {} - -macBuilders.range = function(fig, params) - local pos = fig.getPosition() - -- Resolve rangeKey for token-specific single-ring rendering (R button on - -- a token). The hotkey path sets params.forceFigMode = true to bypass - -- this and always render the fig-leader 6-band overlay, using the token's - -- equivalent baseSize for sizing. - local forceFig = params and params.forceFigMode - local rangeKey = (not forceFig) and ((params and params.rangeKey) or fig.getVar("rangeKey")) or nil - local config, decalURL, maxR, br - local oblong, halfLen, halfWid, rotY - local baseSize -- hoisted: used below for firing-arc decision - if rangeKey and RANGE_CONFIGS[rangeKey] then - config = RANGE_CONFIGS[rangeKey] - decalURL = RANGE_DECAL_URLS[rangeKey] - maxR = RANGE_MAX_RADIUS[rangeKey] - br = TOKEN_BASE_RADIUS[rangeKey] or 0 - else - baseSize = macGetBaseSize(fig) - local hasBaseSize = baseSize ~= nil - if not hasBaseSize then - -- Token hit from hotkey: map rangeKey to base size for sizing. - local tk = fig.getVar("rangeKey") - baseSize = (tk and TOKEN_TO_BASESIZE[tk]) or "small" - end - config = RANGE_CONFIGS[baseSize] or RANGE_CONFIGS.small - decalURL = RANGE_DECAL_URLS.fig_leader[baseSize] - or RANGE_DECAL_URLS.fig_leader.small - maxR = RANGE_MAX_RADIUS.fig_leader - if hasBaseSize then - br = macFigBaseRadius(fig) - local od = macOblongDims(fig, baseSize) - if od then - oblong, halfLen, halfWid, rotY = true, od.halfLen, od.halfWid, od.rotY - end - else - -- Token in forceFig mode: use token's measured radius for offset. - local tk = fig.getVar("rangeKey") - br = (tk and TOKEN_BASE_RADIUS[tk]) or FIG_BASE_RADIUS_IN[baseSize] or 0.5315 - end - end - - -- SWL convention: all distances are measured from the base/token edge. - - -- Vector contours: 6 concentric rings for round bases, 6 concentric - -- stadium outlines for oblong bases (each band sits at band.r outwards - -- from the base edge - same rule, just a different shape). - local lines = {} - for _, band in ipairs(config) do - if oblong then - table.insert(lines, macBuildStadium(pos, halfLen, halfWid, rotY, band.r, - RANGE_COLORS[band.c], 0.1, fig)) - else - table.insert(lines, macBuildRing(pos, band.r + br, RANGE_COLORS[band.c], 0.1, fig)) - end - end - - -- Firing arc lines (4 radial lines dividing into Front/R/Rear/L quadrants). - -- Mirrors the vanilla Projector shader: ProjectorMaterial_ sets - -- _Arc=1 / shader keyword _ARC_ON for every non-27mm base (medium, large, - -- huge, laat, epic, long, snail). 27mm sets _Arc=0. - -- Round bases : lines emanate from the base edge, radial from center. - -- Oblong bases: lines emanate from the centers of the two end - -- semicircles (front cap + rear cap), front lines at +/-45 from the - -- long-axis forward, rear lines at +/-135. - local arcColor = {1, 1, 1, 0.6} - if baseSize and baseSize ~= "small" then - if oblong then - local capOffset = (halfLen or 0) - (halfWid or 0) - local rad = math.rad(rotY) - local fwdX, fwdZ = math.sin(rad), math.cos(rad) - local frontX = pos.x + capOffset * fwdX - local frontZ = pos.z + capOffset * fwdZ - local rearX = pos.x - capOffset * fwdX - local rearZ = pos.z - capOffset * fwdZ - local reach = (halfWid or 0) + (maxR or 6) - local arcs = { - {frontX, frontZ, 45}, -- front-right boundary - {frontX, frontZ, -45}, -- front-left boundary - {rearX, rearZ, 135}, -- rear-right boundary - {rearX, rearZ, -135}, -- rear-left boundary - } - for _, e in ipairs(arcs) do - local cx, cz, deg = e[1], e[2], e[3] - local a = math.rad(rotY + deg) - local sinA, cosA = math.sin(a), math.cos(a) - table.insert(lines, { - points = { - {cx, pos.y + 0.1, cz}, - {cx + reach * sinA, pos.y + 0.1, cz + reach * cosA}, - }, - color = arcColor, - thickness = 0.05, - }) - end - else - local figRotY = fig.getRotation().y - local outerR = (maxR or 6) + br - for _, deg in ipairs({45, 135, 225, 315}) do - local a = math.rad(deg + figRotY) - local sinA, cosA = math.sin(a), math.cos(a) - table.insert(lines, { - points = { - {pos.x + br * sinA, pos.y + 0.1, pos.z + br * cosA}, - {pos.x + outerR * sinA, pos.y + 0.1, pos.z + outerR * cosA}, - }, - color = arcColor, - thickness = 0.05, - }) - end - end - end - - -- Decal halo (filled bands) - flat on the ground under the fig - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then groundY = h.point.y; break end - end - - if oblong then - -- Stadium-shape PNGs are authored per oblong base size: the 6 bands - -- inside the PNG already match the stadium vector outlines because - -- the PNG was generated with the same halfWid/halfLen + dist math. - -- Decal scale = footprint of the outermost band (2*(half + maxR)) - -- in each axis, so the PNG stretches to exactly the right size. - local longSpan = 2 * (halfLen + maxR) - local shortSpan = 2 * (halfWid + maxR) - return { - lines = lines, - decals = { - { - name = "range_decal_" .. fig.getGUID(), - url = decalURL, - position = {pos.x, groundY + 0.02, pos.z}, - rotation = {90, rotY, 0}, - scale = {shortSpan, longSpan, 1}, - } - }, - } - end - - local effectiveMaxR = maxR + br - return { - lines = lines, - decals = { - { - name = "range_decal_" .. fig.getGUID(), - url = decalURL, - position = {pos.x, groundY + 0.02, pos.z}, -- below cohesion halo - rotation = {90, 0, 0}, - scale = {effectiveMaxR * 2, effectiveMaxR * 2, effectiveMaxR * 2}, - } - }, - } -end - -macBuilders.maxmove = function(fig, params) - -- Use the anchor position stored at spawn time so the ring stays put - -- when the fig slides toward the destination, instead of tracking it. - local pos = (params and params.anchorPos) or fig.getPosition() - local baseSize = (params and params.baseSize) or "small" - local speed = (params and params.speed) or 1 - local radii = MAX_MOVE_RADIUS[baseSize] or MAX_MOVE_RADIUS.small - local r = radii[speed] or radii[1] - local color = {0.333, 0.800, 1.000, 0.7} - -- Base ring: small white outline matching the figure's base - -- (BB_MovementProjector renders a base ring on top of the cyan max-move). - local br = FIG_BASE_RADIUS_IN[baseSize] or FIG_BASE_RADIUS_IN.small - local baseColor = {1, 1, 1, 0.8} - -- Oblong base dimensions for the stadium base ring. Use the rotation - -- captured at spawn-time so the ring keeps its orientation even after - -- the fig pivots toward its destination. - local od = macOblongDims(fig, baseSize) - local oblong = od ~= nil - local halfLen, halfWid, rotY - if oblong then - halfLen, halfWid = od.halfLen, od.halfWid - rotY = (params and params.anchorRot) or od.rotY - end - - -- Ground raycast for decal position - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then groundY = h.point.y; break end - end - - if oblong then - -- Oblong base: the cyan max-move envelope stays a round ring (isotropic - -- - any rotation reaches the same radius from the pivot), but the - -- inner white base ring follows the rectangular footprint as a - -- stadium hugging the actual base edge. - return { - lines = { - macBuildRing(pos, r, color, 0.1, fig), - macBuildStadium(pos, halfLen, halfWid, rotY, 0, - baseColor, 0.06, fig), - }, - decals = { - { - name = "maxmove_decal_" .. fig.getGUID(), - url = MAX_MOVE_CYAN_URL, - position = {pos.x, groundY + 0.025, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } - end - - return { - lines = { - macBuildRing(pos, r, color, 0.1, fig), -- outer cyan max-move - macBuildRing(pos, br, baseColor, 0.06, fig), -- inner white base ring - }, - decals = { - { - name = "maxmove_decal_" .. fig.getGUID(), - url = MAX_MOVE_CYAN_URL, - position = {pos.x, groundY + 0.025, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } -end - -macBuilders.deployment = function(_, params) - local pos = params.pos -- {x, y, z} cell center (after offset) - local cell = params.cell -- "r"/"b"/"rh"/... key - local size = DEPLOYMENT_CELL_SIZES[cell] or {6, 6} - local w, d = size[1], size[2] - local cx, cz = pos[1], pos[3] - local hw, hd = w / 2, d / 2 - - local isRed = cell:sub(1, 1) == "r" - local lineColor = isRed and {1, 0.15, 0.15, 0.9} or {0.15, 0.4, 1, 0.9} - local url = isRed and DEPLOYMENT_RED_URL or DEPLOYMENT_BLUE_URL - - local groundY = macRayGroundY(cx, cz, 0) - - return { - lines = { macBuildRect(cx, cz, hw, hd, lineColor, 0.08) }, - decals = { - { - name = "deployment_decal_" .. cell .. "_" .. tostring(cx) .. "_" .. tostring(cz), - url = url, - position = {cx, groundY + 0.03, cz}, - rotation = {90, 0, 0}, - scale = {w, d, 1}, - } - }, - } -end - -macBuilders.cohesion = function(fig, params) - local pos = fig.getPosition() - -- Cohesion overlay = base edge + 3 inches (half Range 1). Round bases get - -- a ring; oblong bases (long/snail) get a stadium-shape concentric to the - -- footprint so the offset stays a constant 3" from the edge. - local rangeKey = fig.getVar("rangeKey") - local oblong, halfLen, halfWid, rotY, r - if rangeKey and TOKEN_BASE_RADIUS[rangeKey] then - r = TOKEN_BASE_RADIUS[rangeKey] + 3.0 - else - local baseSize = macGetBaseSize(fig) or "small" - local od = macOblongDims(fig, baseSize) - if od then - oblong, halfLen, halfWid, rotY = true, od.halfLen, od.halfWid, od.rotY - else - r = (FIG_BASE_RADIUS_IN[baseSize] or FIG_BASE_RADIUS_IN.small) + 3.0 - end - end - if params and params.radius then - oblong, r = false, params.radius - end - - -- Find ground level under the fig so the flat decal sits on the table, - -- not at the fig pivot (which is above the base). - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then - groundY = h.point.y - break - end - end - - if oblong then - -- Halo decal: stretch the round halo PNG into an ellipse aligned with - -- the fig's long axis. Approximates the stadium fade (a true stadium - -- halo would need a custom PNG); good enough visually since the - -- vector stadium outline carries the precise boundary. - local longSpan = 2 * (halfLen + 3.0) - local shortSpan = 2 * (halfWid + 3.0) - return { - lines = { macBuildStadium(pos, halfLen, halfWid, rotY, 3.0, - {1, 1, 1, 0.8}, 0.06, fig) }, - decals = { - { - name = "cohesion_halo_" .. fig.getGUID(), - url = COHESION_HALO_URL, - position = {pos.x, groundY + 0.04, pos.z}, - -- 90 deg pitch lays the decal flat on the table; rotY (yaw) - -- aligns its local Y with the fig's long axis. - rotation = {90, rotY, 0}, - scale = {shortSpan, longSpan, 1}, - } - }, - } - end - - return { - lines = { macBuildRing(pos, r, {1, 1, 1, 0.8}, 0.06, fig) }, - decals = { - { - name = "cohesion_halo_" .. fig.getGUID(), - url = COHESION_HALO_URL, - position = {pos.x, groundY + 0.04, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } -end - -function macRedrawAll() - local lines, decals = {}, {} - -- Preload all texture URLs (invisible decals far below the table) so TTS - -- keeps them cached and we never get the white-square flash on respawn. - local preloadURLs = { - COHESION_HALO_URL, - RANGE_DECAL_URLS.smokeToken, - RANGE_DECAL_URLS.token, RANGE_DECAL_URLS.tokenRangeTwo, - RANGE_DECAL_URLS.poi, RANGE_DECAL_URLS.bombCart, - RANGE_DECAL_URLS.fig_leader.small, RANGE_DECAL_URLS.fig_leader.medium, - RANGE_DECAL_URLS.fig_leader.large, RANGE_DECAL_URLS.fig_leader.huge, - RANGE_DECAL_URLS.fig_leader.laat, RANGE_DECAL_URLS.fig_leader.epic, - RANGE_DECAL_URLS.fig_leader.long, RANGE_DECAL_URLS.fig_leader.snail, - MAX_MOVE_CYAN_URL, - DEPLOYMENT_RED_URL, DEPLOYMENT_BLUE_URL, - } - for i, url in ipairs(preloadURLs) do - table.insert(decals, { - name = "preload_" .. i, - url = url, - position = {0, -200 - i * 0.01, 0}, - rotation = {90, 0, 0}, - scale = {0.01, 0.01, 0.01}, - }) - end - local stale = {} - for key, entry in pairs(activeOverlays) do - local b = macBuilders[entry.type] - if b then - -- Deployment entries have no fig (params-driven). Others need a - -- live Object - drop them silently if destroyed/invalid. - local needsFig = (entry.type ~= "deployment") - if needsFig and (not entry.fig - or type(entry.fig.getPosition) ~= "function") then - stale[#stale + 1] = key - else - local ok, out = pcall(b, entry.fig, entry.params) - if ok and out then - for _, l in ipairs(out.lines or {}) do - table.insert(lines, l) - end - for _, d in ipairs(out.decals or {}) do - table.insert(decals, d) - end - else - stale[#stale + 1] = key - end - end - end - end - for _, k in ipairs(stale) do activeOverlays[k] = nil end - Global.setVectorLines(lines) - Global.setDecals(decals) -end - -function gSpawnCohesion(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":cohesion"] = { - type = "cohesion", fig = fig, params = params or {} - } - macRedrawAll() -end - -function gClearCohesion(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":cohesion"] = nil - macRedrawAll() -end - -function gToggleCohesion(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local key = fig.getGUID() .. ":cohesion" - if activeOverlays[key] then - gClearCohesion(params) - else - gSpawnCohesion(params) - end -end - --- Range polling: a slow timer (5 frames = ~12 fps) that re-draws active --- range rulers so they follow figures in real-time. Stops automatically when --- no range overlay is active anymore - avoids the overnight memory leak. -macRangePollingActive = macRangePollingActive or false - -function macRangePoll() - local hasRange = false - for _, e in pairs(activeOverlays) do - if e.type == "range" then hasRange = true; break end - end - if hasRange then - macRedrawAll() - Wait.frames(macRangePoll, 5) - else - macRangePollingActive = false - end -end - -function gSpawnRange(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":range"] = { - type = "range", fig = fig, params = params or {} - } - macRedrawAll() - if not macRangePollingActive then - macRangePollingActive = true - Wait.frames(macRangePoll, 5) - end -end - -function gClearRange(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":range"] = nil - macRedrawAll() -end - -function gToggleRange(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local key = fig.getGUID() .. ":range" - if activeOverlays[key] then - gClearRange(params) - else - gSpawnRange(params) - end -end - -function gSpawnDeployment(params) - -- params: { cell = "r" | "b" | ..., pos = {x, y, z} } - if not params or not params.cell or not params.pos then return end - local key = "deploy:" .. params.cell .. ":" - .. tostring(params.pos[1]) .. ":" .. tostring(params.pos[3]) - activeOverlays[key] = {type = "deployment", fig = nil, params = params} - macRedrawAll() -end - -function gClearAllDeployment() - for k, e in pairs(activeOverlays) do - if e.type == "deployment" then activeOverlays[k] = nil end - end - macRedrawAll() -end - -function gSpawnMaxMove(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - -- Capture the spawn-time position + yaw. MaxMove is anchored to where - -- the move STARTED - it must not follow the fig as it slides toward - -- the destination. The yaw matters for oblong stadium overlays so the - -- white base ring keeps its orientation while the fig pivots. - local p = (params and params.params) or params or {} - p.anchorPos = fig.getPosition() - p.anchorRot = fig.getRotation().y - activeOverlays[fig.getGUID() .. ":maxmove"] = { - type = "maxmove", fig = fig, params = p - } - macRedrawAll() -end - -function gClearMaxMove(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":maxmove"] = nil - macRedrawAll() -end - -function onObjectPickUp(player_color, obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - local changed = false - for key, entry in pairs(activeOverlays) do - if key:sub(1, plen) == prefix then - -- Cohesion: static, hide during pickup, restore on drop. - -- Range: keep visible; the polling timer redraws each tick so - -- the ruler follows the figure in real-time during the drag. - if entry.type == "cohesion" then - hiddenWhilePickedUp[key] = entry - activeOverlays[key] = nil - changed = true - end - end - end - if changed then macRedrawAll() end -end - -function onObjectDrop(player_color, obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - - -- Defer the overlay restore until the fig has come to rest. Without this, - -- a flick-drop with residual inertia leaves the overlay anchored at the - -- release position while the fig continues to slide across the table. - local function tryRestore() - local fig = getObjectFromGUID(guid) - if not fig then return end -- destroyed during slide - if fig.held_by_color then return end -- picked up again, hold off - local v = fig.getVelocity and fig.getVelocity() - if v then - local speed = math.sqrt((v.x or 0)^2 + (v.y or 0)^2 + (v.z or 0)^2) - if speed > 0.05 then - Wait.frames(tryRestore, 3) - return - end - end - local changed = false - for key, entry in pairs(hiddenWhilePickedUp) do - if key:sub(1, plen) == prefix then - activeOverlays[key] = entry - hiddenWhilePickedUp[key] = nil - changed = true - end - end - if changed then macRedrawAll() end - end - tryRestore() -end - -function onObjectDestroy(obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - local changed = false - for key in pairs(activeOverlays) do - if key:sub(1, plen) == prefix then - activeOverlays[key] = nil - changed = true - end - end - for key in pairs(hiddenWhilePickedUp) do - if key:sub(1, plen) == prefix then - hiddenWhilePickedUp[key] = nil - end - end - if changed then macRedrawAll() end -end - --- Texture preload is handled by macRedrawAll which always includes all PNG --- URLs as invisible decals far below the table. No separate preload needed. - --- ============================================ --- PER-SEAT MODE TOGGLE (Cohesion + Range) --- Each seated player picks their renderer: "mac" (this patch) or --- "windows" (original bundle Projector). When a player triggers an overlay, --- the router checks THEIR mode and uses that renderer. The rendered overlay --- is visible to everyone (TTS engine limitation). --- ============================================ - -playerOverlayMode = playerOverlayMode or {} -- color -> "mac" | "windows" --- Default = "windows" (majority of TTS users). Mac users toggle their seat --- via the floating panel. Same for deployment (table-wide setting). -deploymentMode = deploymentMode or "windows" - -function gGetMode(params) - return playerOverlayMode[params.color] or "windows" -end - -function gGetDeploymentMode() - return deploymentMode -end - -function gToggleDeploymentMode() - deploymentMode = (deploymentMode == "mac") and "windows" or "mac" - -- Wipe any active deployment zones so the next setup uses the new mode. - gClearAllDeployment() - macDeferRefresh() -end - --- Replicates the original showRangeOnHoveredModel toggle, calling the --- aliased Global originals so our Mac overrides don't recapture the path. -function gWindowsRangeToggle(fig) - if not fig then return end - if rangeRuler ~= nil then - pcall(clearRangeRulersOriginalGlobal) - if selectedUnitObj == fig then - selectedUnitObj = nil - return - end - end - if fig.interactable then - pcall(function() spawnRangeRulerOriginalGlobal(fig) end) - selectedUnitObj = fig - end -end - --- Replicates the original showCohesionOnHoveredModel toggle. Uses Global --- aliased originals + Global cohesionRuler state (set by the original). -function gWindowsCohesionToggle(fig) - if not fig then return end - if fig.interactable and selectedUnitObj == fig and cohesionRuler ~= nil then - pcall(clearCohesionRulerOriginalGlobal) - selectedUnitObj = nil - return - end - pcall(clearCohesionRulerOriginalGlobal) - pcall(function() spawnCohesionRulerOriginalGlobal(fig) end) - selectedUnitObj = fig -end - -function gCohesionTrigger(params) - if not params or not params.figGUID then return end - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "windows" - if mode == "windows" then - gWindowsCohesionToggle(fig) - else - gToggleCohesion({figGUID = params.figGUID}) - end -end - -function gRangeTrigger(params) - if not params or not params.figGUID then return end - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "windows" - if mode == "windows" then - gWindowsRangeToggle(fig) - else - gToggleRange({ - figGUID = params.figGUID, - forceFigMode = params.forceFigMode, - rangeKey = params.rangeKey, - }) - end -end - --- UI: floating panel with one toggle button per seated player -function macModeClick(player, _, id) - local color = id:sub(9) -- "macmode_Red" -> "Red" - if player.color ~= color then - broadcastToColor("Only the player at this seat can toggle their mode.", - player.color, {1, 0.5, 0.5}) - return - end - local cur = playerOverlayMode[color] or "windows" - playerOverlayMode[color] = (cur == "mac") and "windows" or "mac" - -- Wipe Mac-side overlays so stale visuals don't linger after the toggle. - -- We don't track per-color ownership, so this clears Cohesion+Range for - -- everyone - acceptable since each player can re-trigger their hotkey. - -- Windows-side overlays (fig-scoped cohesionRuler/rangeRuler) are - -- cleared by iterating objects with active state. - for k, e in pairs(activeOverlays) do - if e.type == "cohesion" or e.type == "range" then - activeOverlays[k] = nil - end - end - macRedrawAll() - for _, obj in ipairs(getAllObjects()) do - if obj.getVar and obj.getVar("cohesionRuler") then - pcall(function() obj.call("clearCohesionRulerOriginal", obj) end) - end - if obj.getVar and obj.getVar("rangeRuler") then - pcall(function() obj.call("clearRangeRulersOriginal", obj) end) - end - end - macDeferRefresh() -end - -local function macFindNodeById(tree, id) - for _, node in ipairs(tree) do - if node.attributes and node.attributes.id == id then return node, tree end - if node.children then - local found, parent = macFindNodeById(node.children, id) - if found then return found, parent end - end - end - return nil, nil -end - --- Tracks the panel's desired active state across refreshes. Persists so a --- click on the close X before the initial refresh is honored. Hidden at first --- load (default = false); the floating "Mac Patch" menu button reveals it. -macModePanelActive = (macModePanelActive == nil) and false or macModePanelActive - -function macModeToggleVisibility() - macModePanelActive = not macModePanelActive - -- Defer the XML rebuild: TTS occasionally throws a UTF-8 byte-buffer - -- encoding error if UI.setXmlTable runs synchronously inside a UI - -- click handler. Deferring one frame + pcall has been reliable. - Wait.frames(function() - pcall(function() - local tree = UI.getXmlTable() or {} - local panel = macFindNodeById(tree, "macModePanel") - if panel then - panel.attributes.active = macModePanelActive and "true" or "false" - UI.setXmlTable(tree) - else - macRefreshModeUI() - end - end) - end, 1) -end - -function macRefreshModeUI() - local seated = Player.getPlayers() - local rows = {{ - tag = "Panel", - attributes = { - color = "transparent", - preferredHeight = "24", - }, - children = { - { - tag = "Text", - attributes = { - text = "Mac TTS U6 Patch", - fontSize = "16", - color = "white", - alignment = "MiddleLeft", - rectAlignment = "MiddleLeft", - }, - }, - { - tag = "Button", - attributes = { - id = "macModePanelClose", - text = "X", - onClick = "macModeToggleVisibility", - color = "#3a1e1e", - textColor = "white", - fontSize = "12", - width = "24", - height = "20", - rectAlignment = "MiddleRight", - }, - }, - }, - }, { - tag = "Text", - attributes = { - text = "Cohesion & Range: pick your renderer", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }} - if #seated == 0 then - table.insert(rows, { - tag = "Text", - attributes = { - text = "(no seated players)", - fontSize = "12", - color = "#888888", - alignment = "MiddleCenter", - }, - }) - end - for _, p in ipairs(seated) do - local mode = playerOverlayMode[p.color] or "windows" - local label = p.color .. " - " .. - (mode == "mac" and "MAC FALLBACK" or "WINDOWS ORIGINAL") - local bg = (mode == "mac") and "#1e7a3a" or "#7a3a1e" - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_" .. p.color, - text = label, - onClick = "macModeClick", - color = bg, - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - end - -- Deployment is table-wide (everyone sees the same zones); use a single - -- global toggle rather than per-seat. - table.insert(rows, { - tag = "Text", - attributes = { - text = "Deployment (table-wide)", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }) - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_deployment", - text = (deploymentMode == "mac") and "DEPLOYMENT: MAC FALLBACK" - or "DEPLOYMENT: WINDOWS ORIGINAL", - onClick = "macDeploymentClick", - color = (deploymentMode == "mac") and "#1e3a7a" or "#7a3a1e", - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - -- Build my panel (will be merged into the existing UI tree below so we - -- don't clobber legionFloatingMenu / Welcome / Chess Clocks etc.). - local panel = { - tag = "Panel", - attributes = { - id = "macModePanel", - active = macModePanelActive and "true" or "false", - rectAlignment = "MiddleRight", - offsetXY = "-10 80", - width = "260", - height = "320", - color = "rgba(0.06,0.06,0.06,0.9)", - padding = "8 8 8 8", - outlineSize = "1 1", - outline = "#303030", - }, - children = {{ - tag = "VerticalLayout", - attributes = { - spacing = "4", - childForceExpandHeight = "false", - childForceExpandWidth = "true", - }, - children = rows, - }}, - } - - local tree = UI.getXmlTable() or {} - -- Remove any stale macModePanel before reinserting the fresh one. - for i = #tree, 1, -1 do - if tree[i].attributes and tree[i].attributes.id == "macModePanel" then - table.remove(tree, i) - end - end - table.insert(tree, panel) - - -- Inject a "Mac Patch" button into the bottom-right legionFloatingMenu - -- (replaces the first interactable=false placeholder button). - local menu = macFindNodeById(tree, "legionFloatingMenu") - if menu and menu.children then - local hasMine = false - for _, c in ipairs(menu.children) do - if c.attributes and c.attributes.id == "macModeMenuButton" then - hasMine = true; break - end - end - if not hasMine then - for i, c in ipairs(menu.children) do - if c.attributes and c.attributes.interactable == "false" then - menu.children[i] = { - tag = "Button", - attributes = { - id = "macModeMenuButton", - fontSize = "10", - onClick = "macModeToggleVisibility", - tooltip = "Toggle Mac TTS U6 Patch panel", - }, - value = "Mac Patch", - } - break - end - end - end - end - - UI.setXmlTable(tree) -end - -function macDeploymentClick(_, _, _) - gToggleDeploymentMode() -end - --- Defer one frame: TTS throws a UTF-8 byte-buffer encoding error if we --- rebuild the UI XML synchronously inside a click handler / seat change. --- pcall guards against any residual race. Promoted to a global function --- so macModeClick / macModeToggleVisibility / gToggleDeploymentMode (all --- defined before this point) can call it via name lookup at call time. -function macDeferRefresh() - Wait.frames(function() pcall(macRefreshModeUI) end, 1) -end -function onPlayerChangeColor(_) macDeferRefresh() end -function onPlayerConnect(_) macDeferRefresh() end -function onPlayerDisconnect(_) macDeferRefresh() end - --- Initial UI build, deferred + pcalled like the rest. -Wait.time(function() pcall(macRefreshModeUI) end, 2) - --- Override hotkey init functions to capture playerColor and route through --- the per-seat trigger. Defining initCohesionHotkeys/initRangebandHotkeys --- here SHADOWS the originals - when the original onLoad runs init*(), our --- versions register the hotkey instead. - -function initCohesionHotkeys() - addHotkey("Show Cohesion On Hovered Model", - function(playerColor, hoverObject, _) - if not hoverObject or not hoverObject.interactable then return end - gCohesionTrigger({ - figGUID = hoverObject.getGUID(), - playerColor = playerColor, - }) - end) -end - -function initRangebandHotkeys() - addHotkey("Show Range On Hovered Model", - function(playerColor, hoverObject, _) - if not hoverObject or not hoverObject.interactable then return end - gRangeTrigger({ - figGUID = hoverObject.getGUID(), - playerColor = playerColor, - forceFigMode = true, -- hotkey always shows 6-band fig overlay - }) - end) -end - --- The vanilla showRangeOnHoveredModel calls spawnRangeRuler(fig) which spawns --- the Range AssetBundle directly. On Mac, the "Oblong Range" shader used by --- long/snail bundles isn't supported (magenta). Override the global function --- to route through gToggleRange so our stadium overlay renders instead. --- Vanilla Range bundle still works for round bases via gWindowsRangeToggle --- when the player explicitly chose Windows mode. -function showRangeOnHoveredModel(hoverObject) - if not hoverObject or not hoverObject.interactable then return end - gToggleRange({figGUID = hoverObject.getGUID(), forceFigMode = true}) -end - --- Note: we deliberately do NOT override the global spawnRangeRuler / --- clearRangeRulers / showRangeOnHoveredModel chain at the Lua scope where --- the vanilla mod defines it. Overriding spawnRangeRuler from a token --- script reproducibly SIGSEGV'd TTS on Mac in May 2026 (Mono GC stack --- corruption). Mac routing happens via the g* functions called from our --- own hotkey/button wrappers; the originals stay reachable for --- Windows-mode players via spawnRangeRulerOriginalGlobal aliases above. - --- END MAC COHESION FALLBACK -""" - - -# ------------------------------------------------------------ -# 2) Wrapper appended AFTER the original Cohesion include (per-seat router) -# ------------------------------------------------------------ -# Strategy: keep the ORIGINAL Cohesion/RangeRulers expansion intact, then -# append wrappers that alias originals + route through the Global router. -# This preserves the original Custom_AssetBundle Projector spawn for -# Windows-mode players (called back as spawnCohesionRulerOriginal). - -COHESION_WRAPPER = r""" --- MAC PATCH per-seat router (Cohesion) --- Aliases the original spawn so Windows-mode players still get their bundle. -spawnCohesionRulerOriginal = spawnCohesionRuler -clearCohesionRulerOriginal = clearCohesionRuler - -function spawnCohesionRuler(cohesionSourceObject) - if not cohesionSourceObject then return end - Global.call("gCohesionTrigger", { - figGUID = cohesionSourceObject.getGUID(), - playerColor = nil, - }) -end - -function clearCohesionRuler() - if self and self.getGUID and self.getGUID() ~= "-1" then - Global.call("gClearCohesion", { figGUID = self.getGUID() }) - end - if cohesionRuler ~= nil then - pcall(clearCohesionRulerOriginal) - end -end - --- Vanilla Order Token's clearTemplates() calls clearCohesionRulers (plural), --- but the save was built from an older mod version that ships only the --- singular clearCohesionRuler. Provide the plural as a delegating stub so --- standby/clearTemplates/etc. don't nil-error on click. -function clearCohesionRulers() - if selectedUnitObj then - pcall(function() selectedUnitObj.setVar("moveState", false) end) - pcall(function() selectedUnitObj.call("clearCohesionRuler") end) - end -end - --- Same root cause as clearCohesionRulers: newer Order Token code paths in --- the live save (e.g. moveUnit -> stopAttack) call helpers the older bundled --- script doesn't ship. Stub them so MOVE/AIM/etc don't nil-error. The bodies --- mirror the current vanilla source but pcall every dependency so we don't --- chain-error if anything else is also missing. -function clearAttackLine() - if attackLine then - for k, v in pairs(attackLine) do - pcall(destroyObject, v) - end - attackLine = nil - end -end - -function exitTargetingMode() - enemyHighlighted = false - attackModeOn = false - pcall(clearRangeRulers) - pcall(unhighlightEnemies) - pcall(clearAttackLine) -end - -function exitAttackMode() - enemyHighlighted = false - attackModeOn = false - pcall(clearRangeRulers) - pcall(unhighlightEnemies) -end --- END MAC PATCH per-seat router (Cohesion) -""" - - -COHESION_BLOCK_RE = re.compile( - r"----#include !/Cohesion\r?\n.*?----#include !/Cohesion\r?\n", - re.DOTALL -) - -RANGE_BLOCK_RE = re.compile( - r"----#include !/RangeRulers\r?\n.*?----#include !/RangeRulers\r?\n", - re.DOTALL -) - -# Maximum Move spawn block in Order_Token (a57c41), inline (not an include). -# Captures from `local maxMoveBundles = getMovementLinks()` up to and -# including the two closing ends of the original `if isDeploy / if ... ~= nil` -# scaffold. The replacement is self-contained (opens 3 ifs, closes all 3), -# so re-applying yields the same canonical block - idempotent. (Earlier -# versions only matched up to setName("Maximum Move") and re-matched their -# own output, accumulating orphan else/end copies on each re-run.) -MAXMOVE_SPAWN_RE = re.compile( - r"local maxMoveBundles = getMovementLinks\(\).*?" - r"\r?\n end\r?\n end\r?\n", - re.DOTALL -) - -MAXMOVE_SPAWN_REPLACEMENT = r"""local maxMoveBundles = getMovementLinks() - local baseSizeMoveBundles = maxMoveBundles[unitData.baseSize] - local maxMoveTemplateBundleToSpawn = baseSizeMoveBundles and baseSizeMoveBundles[unitData.selectedSpeed] - - -- MAC PATCH: permissive condition (covers nil + false) so the overlay - -- also fires when changeSpeed2/3 calls moveUnit() with no isDeploy arg. - if isDeploy ~= true then - if maxMoveTemplateBundleToSpawn ~= nil then - local _macMode = Global.call("gGetMode", {color = macActivePlayerForMove}) - if _macMode == "windows" then - -- WINDOWS ORIGINAL: spawn Custom_AssetBundle Projector - maxMoveTemplate = spawnObject({ - type = "Custom_AssetBundle", - position = {basePos.x, basePos.y + 20, basePos.z}, - rotation = {0, basePos.y, 0}, - scale = {0,0,0} - }) - maxMoveTemplate.setCustomObject({ - type = 0, - assetbundle = maxMoveTemplateBundleToSpawn - }) - maxMoveTemplate.setLock(true) - maxMoveTemplate.use_gravity = false - maxMoveTemplate.setName("Maximum Move") - else - -- MAC FALLBACK: route through the Global Overlays manager - Global.call("gSpawnMaxMove", { - figGUID = selectedUnitObj.getGUID(), - baseSize = unitData.baseSize, - speed = unitData.selectedSpeed, - }) - maxMoveTemplate = nil - end - end - end -""" - -# clearMovementTemplates function: needs to also clear our managed maxmove -# overlay. We replace the whole function body. -# Match up to the first un-indented `end` (= the function's closing end), so -# the regex is idempotent - it matches both the vanilla body and any -# previously patched body shape (which has extra lines past the inner `end`). -CLEAR_MOVEMENT_RE = re.compile( - r"function clearMovementTemplates\(\).*?\r?\nend\b", - re.DOTALL -) - -CLEAR_MOVEMENT_REPLACEMENT = r"""function clearMovementTemplates() - if templateA ~= nil then - destroyObject(templateA) - end - if templateB ~= nil then - destroyObject(templateB) - end - -- MAC PATCH per-seat: destroy bundle Object if present (Windows-mode - -- spawn) AND clear the manager entry (Mac fallback). Either may be set. - if maxMoveTemplate ~= nil then - pcall(destroyObject, maxMoveTemplate) - end - -- Clear only THIS token's MaxMove (keyed by the attached unit), not - -- everyone's - gClearAllMaxMove was killing other tokens' rings when - -- two players had MOVE open at the same time. - if selectedUnitObj then - Global.call("gClearMaxMove", { figGUID = selectedUnitObj.getGUID() }) - end - maxMoveTemplate = nil -end""" - -# Deployment Boundary: replace the Custom_AssetBundle Projector spawn inside -# spawnBoundaryCell (SETUP_CONTROLLER 1cb552) with a Global.call to the manager. -# Idempotent: matches both the vanilla form AND a previously-injected patched -# form (between the MAC PATCH dual-path marker and the closing ` end`). The -# patched alternative is listed first so re-runs prefer it. -DEPLOYMENT_SPAWN_RE = re.compile( - r"(?:" - r"-- MAC PATCH dual-path:.*?\r?\n end" - r"|" - r"local projector = spawnObject\(\{\r?\n" - r" type = \"Custom_AssetBundle\",.*?" - r"projector\.setCustomObject\(\{\r?\n" - r" assetbundle = asset,\r?\n" - r" \}\)" - r")", - re.DOTALL -) - -DEPLOYMENT_SPAWN_REPLACEMENT = r"""-- MAC PATCH dual-path: branch on the table-wide deploymentMode toggle. - local _macDeployMode = Global.call("gGetDeploymentMode") - local projector = nil - if _macDeployMode == "windows" then - projector = spawnObject({ - type = "Custom_AssetBundle", - position = pos, - scale = {0, 0, 0}, - rotation = {0, deployRotations[cell], 0} - }) - projector.setName("Deployment Boundary") - projector.setLock(true) - projector.setCustomObject({ - assetbundle = asset, - }) - else - Global.call("gSpawnDeployment", { cell = cell, pos = pos }) - end""" - -# clearDeploymentBoundary: also clear manager entries. -CLEAR_DEPLOYMENT_RE = re.compile( - r"function clearDeploymentBoundary\(\)\r?\n" - r" local battlefieldObjs = battlefieldZone\.getObjects\(\).*?\r?\n" - r" end\r?\nend", - re.DOTALL -) - -CLEAR_DEPLOYMENT_REPLACEMENT = r"""function clearDeploymentBoundary() - -- MAC PATCH: clear Global Overlays manager entries first. - Global.call("gClearAllDeployment", {}) - local battlefieldObjs = battlefieldZone.getObjects() - for _, obj in pairs(battlefieldObjs) do - if obj.getName() == "Deployment Boundary" then - destroyObject(obj) - end - end -end""" - -# Silhouette URL swap to our Unity-6-compiled bundle. -# -# The upstream BucketheadBits_Silhouette bundle uses Allen White's custom -# Silhouette shader compiled under Unity 2019.4. TTS U6 on Mac fails to -# load that shader at runtime (console: "Shader didn't load correctly for -# AssetBundle material BucketheadBits_Silhouette. Assigning Standard -# shader.") and falls back to Standard, which renders the cylinder opaque. -# -# Our fork-hosted bundle uses the SAME prefab + material + shader source -# (Assets/Sihl/), only the compile target differs (Unity 6 instead of -# Unity 2019.4). Path A from Dicewrench's analysis. -# -# We swap ALL silhouette URLs (default cylinder, snail, long) to the same -# fork URL. Snail/long visual fidelity is acceptable for now (cylinder -# placeholder); per-base meshes are a separate follow-up. -SILHOUETTE_BUNDLE_URL = ( - "https://raw.githubusercontent.com/ironsquadronfr-hub/tts/" - "mac-projector-fallback/mod/data/mac-fallback-assets/" - "silhouette_mac_fallback.unity3d" -) -SILHOUETTE_URL_RE = re.compile( - r'(silhouetteData\s*=\s*)"https?://[^"]*/ugc/[^"]+"' -) -SILHOUETTE_URL_REPLACEMENT = r'\1"' + SILHOUETTE_BUNDLE_URL + '"' - - -# Cleanup of debug prints and corrupted signatures from earlier sessions. -# Safe no-op on a clean save. Matches both the legacy `print("[SIL DEBUG] ...)` -# form AND the chat-visible `printToAll("[SIL] ...", {...})` form. -SIL_DEBUG_STRIP_RE = re.compile( - # Match leading horizontal whitespace only (NOT \s* which would also eat - # the newline before, mashing the function signature against the next - # statement - that was a real bug we hit earlier this session). - r'[ \t]*(?:print\("\[SIL DEBUG\][^\n]*\)|' - r'printToAll\("\[SIL\][^"]*",\s*\{[^}]*\}\))\r?\n' -) -# Repair function signatures that were mashed by the previous \s*-glob bug. -# Idempotent: target the specific mashed patterns; matches are zero on a -# clean save. -SIL_REPAIR_TOGGLE_RE = re.compile( - r'(function toggleSilhouettes\(\))( if silhouetteState then)' -) -SIL_REPAIR_SHOW_RE = re.compile( - r'(function showSilhouette\(\))( for k, guid in pairs\(miniGUIDs)' -) -SIL_REPAIR_SPAWN_RE = re.compile( - r'(function spawnSilhouette\(obj, pos, rot\))( local globals)' -) - - -# Silhouette state-desync fix (upstream bug). -# -# Upstream clearSilhouette assumes removeAttachments()[1] always returns an -# object, but the state variable `silhouetteState` is persisted across saves -# while the physical attachments are not - so a save made with silhouettes -# visible reloads with state=true but no attachments. First SIL click then -# calls clearSilhouette which dereferences nil and crashes. -# -# Two-part patch: -# (1) clearSilhouette: guard the nil case (skip the destruct if no -# attachment), so re-clicks don't crash. -# (2) onload: force silhouetteState=false on every load, since the load -# cannot restore the physical silhouettes anyway. -# -# Also fixes the "two silhouettes stacked" symptom: that happens when -# showSilhouette runs while state is desynced, spawning a second set on top -# of one whose handles have been forgotten. -CLEAR_SILHOUETTE_RE = re.compile( - r"function clearSilhouette\(\).*?silhouetteState = false\r?\nend", - re.DOTALL -) -CLEAR_SILHOUETTE_REPLACEMENT = r"""function clearSilhouette() - -- MAC PATCH: guard nil silToDestroy. silhouetteState can be persisted as - -- true across saves while the physical attachments are not, so a load - -- with state=true + no attachments would otherwise crash here. - for k, guid in pairs(miniGUIDs or {}) do - local obj = getObjectFromGUID(guid) - if obj then - local silToDestroy = obj.removeAttachments()[1] - if silToDestroy then silToDestroy.destruct() end - end - end - silhouetteState = false -end""" - -# Match the existing `function onload()` body (one-line empty or actual -# code). Idempotent: re-runs detect the marker `MAC PATCH: silhouette reset` -# and skip. -SIL_ONLOAD_RESET_LINE = ( - ' -- MAC PATCH: silhouette reset on load (attachments lost across saves)\n' - ' silhouetteState = false\n' -) -ONLOAD_OPEN_RE = re.compile(r"function onload\(\)\r?\n") - - -# SIL button rename (root cause: TTS engine intercepts the click_function -# name "toggleSilhouettes" silently in Mac mode; LCK/R buttons unaffected -# because their click_function names differ. Confirmed empirically 17 mai -# 2026: renaming to "macToggleSil" + forwarder unblocks Mac mode entirely). -# -# We swap the click_function name on the SIL button definition AND inject a -# forwarder function that simply calls toggleSilhouettes(). The original -# toggleSilhouettes is untouched, so Windows mode behavior is identical. -# Idempotent: re-runs detect both markers and skip. -SIL_BUTTON_CLICKFN_OLD = 'click_function = "toggleSilhouettes"' -SIL_BUTTON_CLICKFN_NEW = 'click_function = "macToggleSil"' -SIL_FORWARDER = ''' --- MAC PATCH: rebuilt SIL button click handler. The original click_function --- name "toggleSilhouettes" is silently intercepted by TTS in Mac mode (the --- SIL button plays its click sound/animation but the function is never --- called). Renaming the click_function on the button definition + adding --- this forwarder restores the routing. -function macToggleSil() - toggleSilhouettes() -end --- END MAC PATCH SIL forwarder -''' -SIL_FORWARDER_MARKER = 'function macToggleSil()' - - -RANGE_WRAPPER = r""" --- MAC PATCH per-seat router (Range) - ALIAS ONLY. --- --- HISTORY (do not re-introduce): we previously overrode spawnRangeRuler / --- clearRangeRulers / clearRangeRuler on the ~20 token scripts. That --- reproducibly SIGSEGV'd TTS Mac (Mono GC_clear_stack_inner) when a fig --- hotkey spawned the vanilla Range bundle. Overrides REMOVED - per-seat --- Range routing for fig hotkey lives in Global (gRangeTrigger via our --- initRangebandHotkeys shadow). Tokens' R buttons keep vanilla behavior --- (magenta on Mac, native on Windows) until a per-token Mac fallback --- ships - see TOKEN_BUTTON_WRAPPER for the per-token override path. --- --- The single alias below is REQUIRED: Global macModeClick calls --- obj.call("clearRangeRulersOriginal", obj) on every object holding a --- rangeRuler state, to wipe the vanilla ruler when toggling modes. -clearRangeRulersOriginal = clearRangeRulers --- END MAC PATCH per-seat router (Range) -""" - -# Order_Token-only overrides: intercept the UI button click handlers -# (toggleCohesionRuler, targetingMode, changeSpeedN, moveX) to capture -# playerColor passed by TTS for per-seat routing. -ORDER_TOKEN_BUTTON_OVERRIDES = r""" --- MAC PATCH per-seat router (Order_Token button click overrides) -function toggleCohesionRuler(_, playerColor) - if not rulerOn then - Global.call("gCohesionTrigger", { - figGUID = selectedUnitObj.getGUID(), - playerColor = playerColor, - }) - rulerOn = true - else - selectedUnitObj.call("clearCohesionRuler") - rulerOn = false - end -end - -function targetingMode(_, playerColor) - if not enemyHighlighted then - exitAttackMode() - highlightEnemies() - Global.call("gRangeTrigger", { - figGUID = selectedUnitObj.getGUID(), - playerColor = playerColor, - }) - enemyHighlighted = true - resetRangeButtons() - else - exitTargetingMode() - end -end - --- Capture playerColor for Maximum Move per-seat routing. moveUnit() doesn't --- receive the click color, so each entry-point button stashes it in a --- script-global var that the spawn block reads. Defaults to nil -> Mac mode. - --- initMove / initDeploy: redefine the body INLINE rather than wrap, because --- wrapping (capturing the original via a local + calling it) reproducibly --- broke every Order Token button click in testing (root cause unknown; --- suspected Lua chunk-level upvalue interaction with TTS engine click --- dispatch). The inline body mirrors the vanilla logic + the playerColor --- capture so the MAXMOVE_SPAWN block can route to Mac/Windows per seat. -function initMove(obj, playerColor) - if not selectedUnitObj then return end - macActivePlayerForMove = playerColor - initPos = selectedUnitObj.getPosition() - initRot = selectedUnitObj.getRotation() - selectedUnitObj.call("setStartPos") - moveUnit(false) -end -function initDeploy(obj, playerColor) - if not selectedUnitObj then return end - macActivePlayerForMove = playerColor - initPos = selectedUnitObj.getPosition() - initRot = selectedUnitObj.getRotation() - selectedUnitObj.call("setStartPos") - moveUnit(true) -end - --- INLINE the vanilla bodies (mirror Order_Token.a57c41.lua) rather than --- wrap. The List Builder copies this block to Command Token Custom_Models --- which DO NOT carry the vanilla Order_Token function bodies - so a --- wrap-then-call pattern hits `_macOrigChangeSpeed1 = nil` and crashes the --- moment a Speed/Move button is pressed. Inlining keeps the behavior --- identical on both Order Tokens (vanilla body re-defined) and Command --- Tokens (helper fns setTemplateVariables/clearTemplates/moveUnit exist --- on both because they're part of the include set the List Builder emits). -function changeSpeed1(_, playerColor) - macActivePlayerForMove = playerColor - unitData.selectedSpeed = 1 - setTemplateVariables() - clearTemplates() - moveUnit() -end -function changeSpeed2(_, playerColor) - macActivePlayerForMove = playerColor - unitData.selectedSpeed = 2 - setTemplateVariables() - clearTemplates() - moveUnit() -end -function changeSpeed3(_, playerColor) - macActivePlayerForMove = playerColor - unitData.selectedSpeed = 3 - setTemplateVariables() - clearTemplates() - moveUnit() -end -function moveForward(_, playerColor) - macActivePlayerForMove = playerColor - self.editButton({ - index = 11, click_function = "moveBackwards", - label = "B", tooltip = "Move Backwards" - }) - moveDirection = "forward" - moveUnit() -end -function moveBackwards(_, playerColor) - macActivePlayerForMove = playerColor - self.editButton({ - index = 11, click_function = "moveForward", - label = "F", tooltip = "Move Forward" - }) - moveDirection = "backwards" - moveUnit() -end -function moveLeft(_, playerColor) - macActivePlayerForMove = playerColor - moveDirection = "left" - moveUnit() -end -function moveRight(_, playerColor) - macActivePlayerForMove = playerColor - moveDirection = "right" - moveUnit() -end --- END MAC PATCH per-seat router (Order_Token button click overrides) -""" - -# Wrapper appended to objects that have `function toggleRangeRuler()` - -# the R button on tokens (POI, Smoke, Bomb Cart, Objective). Per-seat -# routing: Mac players get our vector bands (TOKEN-spec via rangeKey), -# Windows players get the vanilla token bundle (spawnTokenRangeRuler). -# The vanilla function is aliased BEFORE override so Windows-mode clicks -# can fall through to it. -TOKEN_BUTTON_WRAPPER = r""" --- MAC PATCH per-seat router (token R button) --- Aliases the vanilla toggleRangeRuler before overriding so Windows-mode --- players keep spawning the token-specific Range bundle (POI / Smoke / --- bomb_cart / objective). Mac-mode players get our vector overlay via --- gRangeTrigger which dispatches to macBuilders.range with the token's --- rangeKey var. -local _macToggleRangeRulerOriginal = toggleRangeRuler -function toggleRangeRuler(_, playerColor) - local mode = Global.call("gGetMode", { color = playerColor }) - if mode == "windows" then - if _macToggleRangeRulerOriginal then - return _macToggleRangeRulerOriginal() - end - return - end - if rangeOn then - Global.call("gClearRange", { figGUID = self.getGUID() }) - rangeOn = false - else - Global.call("gRangeTrigger", { - figGUID = self.getGUID(), - playerColor = playerColor, - }) - rangeOn = true - end -end --- END MAC PATCH per-seat router (token R button) -""" - -TOKEN_BUTTON_WRAPPER_RE = re.compile( - r"\r?\n-- MAC PATCH per-seat router \(token R button\).*?" - r"-- END MAC PATCH per-seat router \(token R button\)\r?\n", - re.DOTALL -) - -# List Builder _loadArmyFromJson nil-guard. Stock SWL never nil-checks the -# result of JSON.decode(text) inside importFromText, so any blur of the -# import InputField with empty/invalid text crashes _loadArmyFromJson at -# `data.armyFaction`. The guard is upstream-able. Idempotent via the -# negative lookahead on the marker comment. -LIST_BUILDER_GUARD_RE = re.compile( - r"(function _loadArmyFromJson\(data\)\r?\n)(?! -- MAC PATCH: guard )" -) -LIST_BUILDER_GUARD_REPLACEMENT = ( - r"\1" - " -- MAC PATCH: guard nil/invalid JSON from onEndEdit blur (upstream bug)\r\n" - " if not data then return end\r\n" -) - -# Idempotent marker - used to detect and remove an existing Mac patch in the -# Global LuaScript before applying a fresh one. -GLOBAL_PATCH_MARKER_RE = re.compile( - r"\r?\n-- ={5,}\r?\n-- MAC COHESION FALLBACK.*?-- END MAC COHESION FALLBACK\r?\n", - re.DOTALL -) - -# Idempotent markers for the per-seat wrappers added to object scripts. -# Strip these before re-applying to avoid double-wrapping. -COHESION_WRAPPER_RE = re.compile( - r"\r?\n-- MAC PATCH per-seat router \(Cohesion\).*?" - r"-- END MAC PATCH per-seat router \(Cohesion\)\r?\n", - re.DOTALL -) -RANGE_WRAPPER_RE = re.compile( - r"\r?\n-- MAC PATCH per-seat router \(Range\).*?" - r"-- END MAC PATCH per-seat router \(Range\)\r?\n", - re.DOTALL -) -ORDER_TOKEN_OVERRIDES_RE = re.compile( - r"\r?\n-- MAC PATCH per-seat router \(Order_Token button click overrides\).*?" - r"-- END MAC PATCH per-seat router \(Order_Token button click overrides\)\r?\n", - re.DOTALL -) - - -TARGET_GUIDS = {"99f1c8", "a57c41"} # Objects with the Cohesion block to replace - - -def collect_patched_scripts(data: dict) -> list: - """Return script_states for the TTS External Editor API (Save & Play). - - Includes the Global script and any object script we patched (matching - TARGET_GUIDS). Format: list of dicts with name, guid, script. - """ - states = [ - {"name": "Global", "guid": "-1", "script": data.get("LuaScript", "")} - ] - - def walk(o): - if isinstance(o, dict): - guid = o.get("GUID", "").lower() - if guid in TARGET_GUIDS and "LuaScript" in o: - states.append({ - "name": o.get("Name", guid), - "guid": guid, - "script": o["LuaScript"], - }) - for v in o.values(): - walk(v) - elif isinstance(o, list): - for x in o: - walk(x) - - walk(data) - return states - - -def send_to_tts(script_states: list) -> bool: - """Send a Save & Play (messageID 1) to TTS via the External Editor API. - - Returns True on success, False if TTS isn't listening (likely the API - is disabled in TTS Configuration). - """ - msg = {"messageID": 1, "scriptStates": script_states} - payload = json.dumps(msg).encode("utf-8") - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(3) - s.connect(("127.0.0.1", 39999)) - s.sendall(payload) - s.close() - return True - except (ConnectionRefusedError, socket.timeout, OSError) as e: - print(f" WARN: could not connect to TTS on port 39999 ({e}).") - print(f" -> Enable Configuration -> Game Tweaks -> 'External Editor API' in TTS.") - return False - - -def patch_object_scripts(data: dict) -> tuple: - """Walk the JSON tree, replace Cohesion and Range blocks in object scripts. - - Cohesion block: only on TARGET_GUIDS (Unit_Leader, Order_Token). - Range block: on every object containing the !/RangeRulers include - (Order_Token, Tokens, POI, bomb_cart, etc. - ~20 objects). - - Returns (n_cohesion_patched, n_range_patched, n_dep_patched). - """ - n_coh, n_rng, n_dep = 0, 0, 0 - - def walk(o): - nonlocal n_coh, n_rng, n_dep - if isinstance(o, dict): - guid = o.get("GUID", "").lower() - if "LuaScript" in o: - ls = o["LuaScript"] - changed = False - - # Strip any previous per-seat wrappers so re-runs are idempotent. - for rx in (COHESION_WRAPPER_RE, RANGE_WRAPPER_RE, - ORDER_TOKEN_OVERRIDES_RE, TOKEN_BUTTON_WRAPPER_RE): - new_ls, n = rx.subn("", ls) - if n > 0: - ls = new_ls - changed = True - - # Cleanup: strip stale debug prints + repair any signatures - # mashed by an earlier (overzealous) strip pass. Both are - # no-ops on a clean save. - new_ls, n = SIL_DEBUG_STRIP_RE.subn("", ls) - if n > 0: - ls = new_ls - changed = True - for rx in (SIL_REPAIR_TOGGLE_RE, SIL_REPAIR_SHOW_RE, SIL_REPAIR_SPAWN_RE): - new_ls, n = rx.subn(r'\1\n\2', ls) - if n > 0: - ls = new_ls - changed = True - - # (Diagnostic SIL prints removed 17 mai 2026 once mute Mac - # mode confirmed. SIL_DEBUG_STRIP_RE above stays as a cleanup - # pass for older saves that still carry the prints.) - - # Silhouette URL swap to our Unity-6-compiled bundle. - # Applies to any object that defines spawnSilhouette - # (Unit_Leader 99f1c8, Bomb Cart b497e1, POI Token 761483). - # Idempotent: the regex only matches Steam UGC URLs (/ugc/), - # not our fork URL, so re-runs are no-ops. - new_ls, n = SILHOUETTE_URL_RE.subn(SILHOUETTE_URL_REPLACEMENT, ls) - if n > 0: - ls = new_ls - changed = True - - # SIL button rename + forwarder. Bypasses the TTS engine - # bug where the click_function name "toggleSilhouettes" is - # silently muted in Mac mode. Idempotent: replace() is no-op - # once swap is done, marker check prevents double-injection. - if SIL_BUTTON_CLICKFN_OLD in ls: - ls = ls.replace(SIL_BUTTON_CLICKFN_OLD, SIL_BUTTON_CLICKFN_NEW) - changed = True - if SIL_BUTTON_CLICKFN_NEW in ls and SIL_FORWARDER_MARKER not in ls: - ls = ls.rstrip() + '\n' + SIL_FORWARDER - changed = True - - # Cohesion: KEEP the original include block, append per-seat - # wrapper after it. Only Unit_Leader / Order_Token have it. - if guid in TARGET_GUIDS: - def coh_repl(m): - return m.group(0) + COHESION_WRAPPER - new_ls, n = COHESION_BLOCK_RE.subn(coh_repl, ls, count=1) - if n > 0: - ls = new_ls - n_coh += 1 - changed = True - - # Silhouette desync fix (any object with clearSilhouette). - # Guard the nil-attachment case and reset silhouetteState in - # onload. Idempotent: the regex matches both the vanilla - # body and our previously-patched body (same shape). - if "function clearSilhouette()" in ls: - new_ls, n = CLEAR_SILHOUETTE_RE.subn(CLEAR_SILHOUETTE_REPLACEMENT, ls, count=1) - if n > 0: - ls = new_ls - changed = True - # Inject the state reset at the top of onload. - if "MAC PATCH: silhouette reset on load" not in ls: - new_ls, n = ONLOAD_OPEN_RE.subn( - "function onload()\n" + SIL_ONLOAD_RESET_LINE, - ls, count=1, - ) - if n > 0: - ls = new_ls - changed = True - - # Range: KEEP block, append wrapper. ~21 objects. - def rng_repl(m): - return m.group(0) + RANGE_WRAPPER - new_ls, n = RANGE_BLOCK_RE.subn(rng_repl, ls, count=1) - if n > 0: - ls = new_ls - n_rng += 1 - changed = True - - # List Builder import guard. Applies to BLUE/RED LIST - # BUILDER (any object that defines _loadArmyFromJson). - # Fixes a pre-existing upstream crash where blurring the - # import InputField with empty/invalid text fires - # onEndEdit -> importFromText -> JSON.decode("") -> nil -> - # data.armyFaction crash. Idempotent. - if "function _loadArmyFromJson(data)" in ls: - new_ls, n = LIST_BUILDER_GUARD_RE.subn( - LIST_BUILDER_GUARD_REPLACEMENT, ls, count=1 - ) - if n > 0: - ls = new_ls - changed = True - - # Maximum Move spawn (only in Order_Token a57c41) - Mac only - if guid == "a57c41": - new_ls, n = MAXMOVE_SPAWN_RE.subn(MAXMOVE_SPAWN_REPLACEMENT, ls, count=1) - if n > 0: - ls = new_ls - changed = True - new_ls, n = CLEAR_MOVEMENT_RE.subn(CLEAR_MOVEMENT_REPLACEMENT, ls, count=1) - if n > 0: - ls = new_ls - changed = True - # Button click overrides (per-seat capture of playerColor) - ls = ls + ORDER_TOKEN_BUTTON_OVERRIDES - changed = True - - # Token R button: append toggleRangeRuler override on any - # object that defines that function (POI, Smoke, BombCart, - # Objective, etc.). Routes the click to the Mac fallback - # Range overlay sized via TOKEN_BASE_RADIUS. (Already-applied - # case is handled by the strip loop above.) - if "function toggleRangeRuler()" in ls: - ls = ls + TOKEN_BUTTON_WRAPPER - changed = True - - # Deployment Boundary (only in SETUP_CONTROLLER 1cb552) - Mac only - if guid == "1cb552": - new_ls, n = DEPLOYMENT_SPAWN_RE.subn(DEPLOYMENT_SPAWN_REPLACEMENT, ls, count=1) - if n > 0: - ls = new_ls - n_dep += 1 - changed = True - new_ls, n = CLEAR_DEPLOYMENT_RE.subn(CLEAR_DEPLOYMENT_REPLACEMENT, ls, count=1) - if n > 0: - ls = new_ls - changed = True - - if changed: - o["LuaScript"] = ls - - for v in o.values(): - walk(v) - elif isinstance(o, list): - for x in o: - walk(x) - - walk(data) - return n_coh, n_rng, n_dep - - -def main(): - args = [a for a in sys.argv[1:] if not a.startswith("--")] - flags = [a for a in sys.argv[1:] if a.startswith("--")] - if len(args) != 2: - print(f"Usage: {sys.argv[0]} [--reload]") - sys.exit(1) - src = Path(args[0]) - dst = Path(args[1]) - reload_tts = "--reload" in flags - - with src.open("r") as f: - data = json.load(f) - - # Rename so the patched save is recognizable in TTS load list. - data["SaveName"] = "SWL BETA - MAC PATCH" - - # 1. Append manager + handlers to Global LuaScript (idempotent: remove - # any previous Mac patch block first, then append fresh). - original_global_len = len(data.get("LuaScript", "")) - existing = data.get("LuaScript", "") - cleaned, n_removed = GLOBAL_PATCH_MARKER_RE.subn("", existing) - data["LuaScript"] = cleaned + GLOBAL_PATCH_LUA - new_global_len = len(data["LuaScript"]) - note = " (replaced existing patch)" if n_removed else "" - print(f"Global LuaScript: {original_global_len} -> {new_global_len} bytes{note}") - print() - - # 2. Replace Cohesion + Range + Deployment blocks in object scripts. - print("Object script patches:") - n_coh, n_rng, n_dep = patch_object_scripts(data) - print(f" Cohesion blocks replaced: {n_coh}") - print(f" Range blocks replaced: {n_rng}") - print(f" Deployment blocks replaced: {n_dep}") - print() - - dst.parent.mkdir(parents=True, exist_ok=True) - with dst.open("w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - print(f"Written: {dst}") - print(f" Save Name: {data.get('SaveName', '?').strip()}") - print(f" Version: {data.get('VersionNumber', '?')}") - - # Mirror to TTS Saves folder so it appears in Games -> Save & Load. - # Picks the next free TS_Save_N.json slot (TTS scans these on load). - saves_dir = Path.home() / "Library" / "Tabletop Simulator" / "Saves" - if saves_dir.exists(): - existing = sorted( - int(p.stem.split("_")[-1]) - for p in saves_dir.glob("TS_Save_*.json") - if p.stem.split("_")[-1].isdigit() - ) - # Reuse the highest existing slot if its content matches the prefix - # "SWL BETA - MAC PATCH"; else pick next free number. - target = None - for n in reversed(existing): - p = saves_dir / f"TS_Save_{n}.json" - try: - with p.open("r") as f: - if '"SWL BETA - MAC PATCH"' in f.read(512): - target = p - break - except OSError: - continue - if target is None: - next_n = (existing[-1] if existing else 0) + 1 - target = saves_dir / f"TS_Save_{next_n}.json" - with target.open("w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - print(f" Mirrored to: {target}") - - if reload_tts: - print() - print("Hot-reloading into TTS via External Editor API...") - states = collect_patched_scripts(data) - print(f" Sending {len(states)} script(s): " - + ", ".join(f"{s['name']}({s['guid']})" for s in states)) - if send_to_tts(states): - print(" OK Sent. TTS should restart Lua with the new code " - "(table/objects state preserved).") - - -if __name__ == "__main__": - main() diff --git a/mac-patcher/requirements.txt b/mac-patcher/requirements.txt deleted file mode 100644 index fd6ee21e..00000000 --- a/mac-patcher/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -UnityPy>=1.10 -Pillow>=10.0 -requests>=2.31 diff --git a/mac-patcher/retro-cohesion.md b/mac-patcher/retro-cohesion.md deleted file mode 100644 index a67ea58c..00000000 --- a/mac-patcher/retro-cohesion.md +++ /dev/null @@ -1,170 +0,0 @@ -# Rétro-ingé Cohesion Ruler - -Rédigé en mode prod, base pour le refactor event-driven vector lines (15 mai 2026, post-finding session 14-15 mai). - -## 1. Définition fonctionnelle (rappel règles SWL) - -Le **Cohesion Ruler** matérialise la **distance de cohésion d'unité** : tous les minis d'une même unité doivent rester à une distance maximale du leader d'unité (sinon malus / dispersion). Le ruler aide à vérifier visuellement cette contrainte pendant le tour. - -Distance de cohésion : **0.5 inch** (1,27 cm) entre les bords des socles, règle officielle SWL. - -## 2. Visuel attendu - -3 tailles, indexées par `baseSize` du leader : - -| Clé | Base size physique | Bundle | -|---|---|---| -| `small` | 27 mm | `https://steamusercontent-a.akamaihd.net/ugc/2482129948496305632/...` | -| `medium` | 50 mm | `https://steamusercontent-a.akamaihd.net/ugc/2482129948496305877/...` | -| `large` | 70 mm | `https://steamusercontent-a.akamaihd.net/ugc/2482129948496305957/...` | - -Rendu côté Windows (TTS Unity 2019.4) : cercle semi-transparent vert/cyan projeté au sol via Unity Projector, centré sur la fig. **À extraire empiriquement** via UnityPy + référence Workshop pour la couleur/alpha exacts (en TODO ci-dessous). - -Comportement Projector : drape sur le relief de la table (rochers, élévations) grâce au Projector legacy. - -## 3. Déclencheurs (qui peut spawn/clear le ruler ?) - -### Spawn - -1. **Hotkey global "Show Cohesion On Hovered Model"** — `StarWarsLegion.lua:611-619` - - Bind via `addHotkey()` côté Global - - Appelle `showCohesionOnHoveredModel(hoverObject)` qui : - - Si déjà selected → clear + reset `selectedUnitObj` - - Sinon → `clearCohesionRuler() + spawnCohesionRuler(hoverObject)` - - **État global** `selectedUnitObj` tracké côté Global (variable du script `StarWarsLegion.lua` via `require('!/Cohesion')`) - -2. **Bouton "COHESION" sur Order Token** — `Order_Token.a57c41.lua:347-352, 369-378` - - Bouton créé pendant l'activation d'une unité - - `toggleCohesionRuler()` toggle via flag `rulerOn` local au Order Token - - Appelle `selectedUnitObj.call("spawnCohesionRuler", selectedUnitObj)` → s'exécute dans le script de la **fig sélectionnée** (Unit_Leader.99f1c8) - -3. **Re-spawn automatique après mouvement** — `Unit_Leader.99f1c8.lua:278-286` - - `dropCoroutine()` attend que `getVelocity().y == 0` (fig au sol) - - Si `moveState == true` (= en cours de mesure de mouvement) → `spawnCohesionRuler(self)` - -### Clear - -1. **Hotkey re-press** — toggle via `showCohesionOnHoveredModel` (idem spawn, second appel) - -2. **Bouton COHESION re-press** — toggle via `toggleCohesionRuler` (idem) - -3. **Bouton invisible "unitID" sur Unit_Leader** — `Unit_Leader.99f1c8.lua:45` - - Bouton au centre de la fig avec label = numéro d'unité, couleur alpha 0.01 (quasi-invisible) - - `click_function = "clearCohesionRuler"` - - C'est l'overlay cliquable du numéro affiché sur chaque fig - -4. **onPickedUp sur Unit_Leader** — `Unit_Leader.99f1c8.lua:274-276` - - **Event-driven déjà existant** : pickup la fig → clear automatique - -5. **`standbyTokens()` global** — `StarWarsLegion.lua:562-571` - - Boucle sur `getAllObjects()` et destroy tout objet nommé `"Cohesion Ruler"` (et "Range Ruler", "Movement Template", "Deployment Boundary") - - Déclenché par... à tracer (probablement reset de partie ou hotkey debug) - -6. **`removeLockedRulers()` GAME_CONTROLLER** — `GAME_CONTROLLER.623b03.lua:148-155` - - Boucle sur `getAllObjects()` et destroy "Cohesion Ruler" / "Range Ruler" - - Appelé par la fonction de reload (`reloadObj` voisine) - -7. **`clearCohesionRulers()` (pluriel) Order Token** — `Order_Token.a57c41.lua:788-793` - - Appelé par `clearTemplates()` lors du reset après mouvement - - `selectedUnitObj.setVar("moveState", false)` + `selectedUnitObj.call("clearCohesionRuler")` - -## 4. Lifecycle complet - -``` -[Spawn] - showCohesionOnHoveredModel(fig) [hotkey hover] - toggleCohesionRuler() [via Order Token bouton] - dropCoroutine() [auto si moveState] - ↓ - spawnCohesionRuler(fig): - 1. unitData = fig.getTable("unitData") - 2. bundleURL = getCohesionLinks()[unitData.baseSize] - 3. spawnObject(Custom_AssetBundle, pos = fig.pos + Y+20, scale = 0, rot = (0, fig.rot.y, 0)) - 4. setCustomObject({type=0, assetbundle=bundleURL}) - 5. setLock(true), use_gravity=false, setName("Cohesion Ruler") - ↓ -[Ruler affiché — STATIQUE, ne suit pas la fig] - Note: pas de onFixedUpdate. Si fig bouge sans pickup (impossible normalement - car les figs sont lockables), le ruler reste à l'ancienne position. - -[Clear] - showCohesionOnHoveredModel(same fig) [hotkey re-press toggle] - toggleCohesionRuler() [re-press toggle] - onPickedUp(fig) [pickup auto] - clearCohesionRuler() [click invisible "unitID" button] - standbyTokens() [global reset] - removeLockedRulers() [reload] - clearCohesionRulers() [via Order Token clearTemplates] - ↓ - clearCohesionRuler(): - 1. destroyObject(cohesionRuler) - 2. cohesionRuler = nil - -[Re-spawn auto post-drop] - onDropped → checkVelocity → si moveState → startLuaCoroutine(dropCoroutine) - ↓ - dropCoroutine: - while velocity.y ~= 0: yield - if moveState == true: spawnCohesionRuler(self) -``` - -## 5. État scopé : ATTENTION pattern hétérogène - -`cohesionRuler` est une variable Lua **scopée au script Lua qui fait le require**. Chaque objet qui require `!/Cohesion` a SA propre `cohesionRuler`. Trois scopes coexistent : - -| Site require | Scope `cohesionRuler` | -|---|---| -| `StarWarsLegion.lua:8` (Global) | Script Global | -| `Unit_Leader.99f1c8.lua:1` (chaque fig leader) | Par-fig | -| `Order_Token.a57c41.lua:4` (chaque Order Token) | Par-Order-Token | - -**Conséquence pratique** : -- Hotkey hover → spawn dans le scope Global → `cohesionRuler` du Global mis à jour, **pas celui de la fig** -- Bouton COHESION → `selectedUnitObj.call("spawnCohesionRuler", selectedUnitObj)` → exécute dans le scope de la fig → `cohesionRuler` de la fig mis à jour -- `onPickedUp(fig)` → clear dans le scope de la fig → ne clear PAS un ruler spawn via hotkey hover (mais le bouton invisible "unitID" est sur la fig, donc dans le scope fig...) - -**Bug latent connu (non reproduit)** : si tu spawn via hotkey hover, puis tu pickup une autre fig, le ruler du Global subsiste. À garder en tête pour le refactor (à corriger ou pas selon scope). - -## 6. Filtres de cleanup global - -Deux fonctions scannent `getAllObjects()` et destroy par nom : - -| Fonction | Fichier | Cible | -|---|---|---| -| `standbyTokens()` | StarWarsLegion.lua:562 | "Cohesion Ruler" + "Range Ruler" + "Movement Template" + "Deployment Boundary" | -| `removeLockedRulers()` | GAME_CONTROLLER.623b03.lua:148 | "Cohesion Ruler" + "Range Ruler" | - -**Impact refactor** : si on remplace l'Object Custom_AssetBundle par des vector lines (qui ne sont pas des Objects), ces deux filtres deviennent **caducs**. Il faudra leur substituer un clear de la table d'état globale + `Global.setVectorLines({})`. - -## 7. Paramètres visuels exacts — TODO - -À extraire empiriquement via UnityPy sur les bundles small/medium/large : - -- [ ] Bundle `halfcohesion_27mm.unity3d` (ou équivalent small) : material color, alpha, orthographicSize du Projector, animation éventuelle -- [ ] Bundle medium (50mm) : idem -- [ ] Bundle large (70mm) : idem -- [ ] Screenshot référence côté Workshop (rendu attendu côté Windows TTS 2019.4 / U6 non-magenta) - -Scripts UnityPy disponibles dans `Mod TTS SWL/` (`inspect_cohesion.py`, `inspect_externals.py`). À adapter pour extraire les params materials. - -## 8. Implications pour le refactor - -### Bonnes nouvelles -- API publique petite et conservable : `showCohesionOnHoveredModel`, `spawnCohesionRuler`, `clearCohesionRuler` -- 80% du pattern event-driven déjà en place (`onPickedUp`, `dropCoroutine`) -- Lifecycle bien défini, déclencheurs énumérés exhaustivement - -### Décisions à prendre dans le design doc -1. **Unifier les scopes** : faire que `cohesionRuler` soit une table globale indexée par GUID (Variante A) plutôt que par-script. Résout le bug latent (§5) et permet le pattern multi-figs simultanées. -2. **Remplacer les filtres de cleanup** (§6) par des appels à la fonction de clear globale. -3. **Reproduire le drape sur relief** via `Physics.cast()` par segment, comme le POC validé du 14-15 mai. -4. **Compatibilité Order Token bouton** : conserver la sémantique toggle `rulerOn` ou simplifier. - -(Note : esthétique du rendu vector lines hors specs — on traitera une fois le dossier refactor clos.) - -## 9. Références - -- Code : `swlegion-tts/mod/src/includes/Cohesion.ttslua`, `data/CohesionLinks.ttslua`, `StarWarsLegion.lua`, `StarWarsLegion/Unit_Leader.99f1c8.lua`, `StarWarsLegion/Order_Token.a57c41.lua`, `StarWarsLegion/GAME_CONTROLLER.623b03.lua` -- Bundles : cache TTS local, sources Unity dans `Mod TTS SWL/UnityProject-U6/Assets/` -- Session 14-15 mai validation empirique : `~/.claude/projects/-Users-martinpourrat-MARTIN-Star-Wars-Legion/memory/mod-tts-swl/session-14-mai.md` -- Plan B identifié : `~/.claude/projects/-Users-martinpourrat-MARTIN-Star-Wars-Legion/memory/mod-tts-swl/paths-forward.md` diff --git a/mac-patcher/retro-deployment.md b/mac-patcher/retro-deployment.md deleted file mode 100644 index 20d1741e..00000000 --- a/mac-patcher/retro-deployment.md +++ /dev/null @@ -1,76 +0,0 @@ -# Rétro-ingé Deployment Boundary - -## Visuel attendu - -Zones de déploiement rouge/bleu peintes au sol au début de la partie selon le scenario. Pattern matriciel avec 14 codes de cellule : - -| Code | Sens | -|---|---| -| `r` / `b` | Base red / blue | -| `rh` / `bh` | Home (zone arrière) | -| `rs` / `bs` | Side (zone latérale) | -| `rss` / `bss` | Side stretched | -| `rl` / `bl` | Long (zone allongée, spawn 2 cellules : sX + ccX) | -| `rc` / `bc` | Corner | -| `rcc` / `bcc` | Corner-corner (autre orientation) | - -URLs bundles dans `deployLinks` (`SETUP_CONTROLLER.1cb552.lua:188-202`). - -Plusieurs codes partagent la même URL (e.g. `rh = rs = rss` → même bundle, rotation différente). Distinct rotations : `r=0, rh=0, rs=90, rss=90, ...` (`deployRotations` ligne 204). - -Rendu côté Windows : grand rectangle/zone projeté au sol via Projector. Couleurs rouge/bleu. **À extraire via UnityPy** (task #13). - -## Spawn - -**1 seul déclencheur** : `spawnDeploymentBoundary(matrix)` — `SETUP_CONTROLLER.1cb552.lua:267-314` - -- Reçoit une matrice 12×N (codes par cellule) inversée puis lue ligne par ligne -- Pour chaque cellule non-vide : `spawnBoundaryCell(cell, x, z)` qui spawn 1 Custom_AssetBundle au pos calculé `{xStart + 6*(x-1), yValue, zStart - 6*(z-1)}` + `deployOffset[cell]` -- Cas spécial `bl`/`rl` : spawn 2 cellules (sX + ccX) pour matérialiser le L - -Appelé par `SETUP_CONTROLLER.1cb552.lua:369` (probablement après sélection de la carte Deployment via UI menu). - -## Clear - -**1 seul déclencheur** : `clearDeploymentBoundary()` — ligne 316-323 -- Boucle sur `battlefieldZone.getObjects()` et `destroyObject` chaque objet nommé `"Deployment Boundary"` - -Aussi via : -- **`standbyTokens()`** — `StarWarsLegion.lua:567` — destroy global par nom - -(Pas dans `removeLockedRulers` de GAME_CONTROLLER : Deployment n'est pas dans la liste.) - -## Lifecycle - -``` -[Setup partie] - Menu UI "Mount Deployment" → checkDeployment() → spawnDeploymentBoundary(matrix) - ↓ - Loop sur matrix → pour chaque cell : spawnBoundaryCell - ↓ - Custom_AssetBundle spawn statique (scale 0, locked, no gravity) - -[Clear] - Menu UI "Remove Overlay" → clearDeploymentBoundary() - ↓ - Boucle battlefieldZone → destroyObject par nom - -[Aussi : standbyTokens() global au reset] -``` - -**Pattern : 100% statique.** Pas de suivi, pas d'event. Spawn au setup, clear quand on remove. Plus simple que Cohesion et Range. - -## Différences clés vs Cohesion/Range - -1. **Pas de fig source** — c'est un overlay de zone, pas un overlay attaché à un objet mobile. Pas besoin de drape sur relief si la table est plate (à confirmer : sur certaines tables custom avec relief, est-ce que la zone de déploiement doit draper ? probablement oui pour cohérence visuelle). -2. **Multi-cellules** par scenario — peut-être 10-20 Custom_AssetBundle spawn simultanément (vs 1 pour Cohesion/Range). -3. **Pas de variable d'état scopée** — pas de `deploymentRuler = nil` ; le code itère sur `battlefieldZone.getObjects()` filtré par nom pour le cleanup. - -## Implications refactor - -- API publique à conserver : `spawnDeploymentBoundary(matrix)`, `clearDeploymentBoundary()` -- 14 bundle URLs (mais visuellement ~6 zones distinctes vu les doublons URL) → 6 sets de params visuels à extraire (task #13) -- Pas d'event-driven nécessaire — statique simple -- En vector lines : chaque cellule = un polygone fermé `setVectorLines({{points={p1,p2,p3,p4,p1}, ...}, ...})`. Pas de Physics.cast si table plate, sinon raycast par segment du contour -- Filtre cleanup `standbyTokens` à adapter pour clear la table d'état globale (ou laisser un `Global.setVectorLines({})` de la collection deployment) -- **Décision design** : Deployment partage-t-il la même collection `Global.setVectorLines()` que Cohesion/Range, ou faut-il un canal séparé ? `Global.setVectorLines` est singleton donc tout doit cohabiter dans la même collection. Implication : la table d'état globale doit indexer par type (`cohesion` / `range` / `deployment`) pour gérer les clears partiels. diff --git a/mac-patcher/retro-movement.md b/mac-patcher/retro-movement.md deleted file mode 100644 index 579aaca3..00000000 --- a/mac-patcher/retro-movement.md +++ /dev/null @@ -1,122 +0,0 @@ -# Rétro-ingé Movement Template + Maximum Move - -**Correction** : Movement implique **2 objets visuels** spawn ensemble lors de l'activation de mouvement. Un seul des deux est cassé magenta. - -## Composant 1 : Movement Template A/B (templates 3D courbes) — NON AFFECTÉ - -Code : `Order_Token.a57c41.lua:480-529` - -```lua -templateA = spawnObject({ type = "Custom_AssetBundle", scale = {1,1,1} ... }) -templateA.setCustomObject({ assetbundle = ..., material = 1 }) -templateA.setColorTint(...) -templateA.setName("Movement Template (A)") -``` - -- **scale = {1,1,1}** → mesh visible (pas un Projector caché) -- **`material = 1`** + **`setColorTint()`** → material standard avec colorTint TTS exposé -- Bundles `longBundle` / `shortBundle` / `sharedBundle` par speed (templateInfo) -- **Pas affecté par bug magenta** : c'est un mesh classique, pas un Projector receiver - -## Composant 2 : Maximum Move (cercle de portée) — CASSÉ MAGENTA ✅ - -Code : `Order_Token.a57c41.lua:552-574` - -```lua -maxMoveTemplate = spawnObject({ - type = "Custom_AssetBundle", - position = {basePos.x, basePos.y + 20, basePos.z}, - scale = {0,0,0} -- ← scale 0, pattern Projector -}) -maxMoveTemplate.setCustomObject({ - type = 0, - assetbundle = maxMoveTemplateBundleToSpawn -}) -maxMoveTemplate.setLock(true) -maxMoveTemplate.use_gravity = false -maxMoveTemplate.setName("Maximum Move") -- ← nom différent de "Movement Template" -``` - -- **scale = {0,0,0}** → Projector legacy (même pattern que Cohesion/Range/Deployment) -- **Pas de setColorTint** sur ce spawn -- **CASSÉ MAGENTA** sur Mac confirmé par Martin - -### Bundles (`includes/data/MovementLinks.ttslua`) - -`getMovementLinks()` retourne table indexée `[baseSize][selectedSpeed]` : - -| baseSize | Taille | Speeds | -|---|---|---| -| `small` | 27mm | 3 bundles (speed 1/2/3) | -| `medium` | 50mm | 3 | -| `large` | 70mm | 3 | -| `huge` | 100mm | 3 | -| `laat` | 120mm | 3 | -| `epic` | 150mm | 3 | -| `long` | 100×175mm oblong | 3 | -| `snail` | 100×200mm oblong | 3 | - -**Total : 24 bundles uniques** pour le cercle de portée max. - -Rendu visuel attendu côté Windows : cercle/anneau projeté au sol matérialisant la portée maximale de mouvement à cette vitesse. **Couleur/alpha à extraire via UnityPy** (task #13). - -## Déclencheurs - -### Spawn -**1 seul déclencheur** : flow d'activation mouvement via Order Token -- Bouton speed (1/2/3) cliqué sur Order Token → set `unitData.selectedSpeed` -- Une fonction de mouvement (autour de `Order_Token.a57c41.lua:480+`) spawn `templateA`, `templateB`, ET `maxMoveTemplate` ensemble -- Conditional : `if isDeploy == false` → pas de Maximum Move pendant la phase de déploiement (ligne 556) - -### Clear -**Fonction `clearMovementTemplates()`** — `Order_Token.a57c41.lua:776-786` -```lua -function clearMovementTemplates() - if templateA ~= nil then destroyObject(templateA) end - if templateB ~= nil then destroyObject(templateB) end - if maxMoveTemplate ~= nil then destroyObject(maxMoveTemplate) end -end -``` - -Appelé par : -- `clearTemplates()` (ligne 770-774) → appelé après le drop final du mouvement -- Aucun cleanup global (Maximum Move PAS dans `standbyTokens()` ni `removeLockedRulers()`) - -## Lifecycle - -``` -[Spawn] - Order Token bouton speed (1/2/3) → spawn flow - ↓ - templateA, templateB (mesh visible) + maxMoveTemplate (Projector) - ↓ - L'utilisateur déplace templateA jusqu'à la position cible - → contrôle visuel "ma fig peut-elle aller là" avec le cercle Maximum Move - -[Clear] - Drop final (fig placée) → clearTemplates() → destruct 3 objets ensemble -``` - -**Pattern : statique au spawn, pas de suivi de fig**. Le cercle Maximum Move est positionné une fois à `basePos + Y20` et reste là pendant que tu manipules les templates de mouvement. - -## État scopé - -`maxMoveTemplate` est une variable par Order Token (`require('!/Cohesion')` ligne 4 mais maxMoveTemplate vient du flow Order_Token directement, pas d'include). Pas de partage Global / Unit_Leader. - -## Implications refactor - -- API publique à conserver : le spawn et `clearMovementTemplates()` (mais cette dernière mélange templates A/B + maxMoveTemplate) -- 24 bundles cercle de portée → 8 sets de params visuels distincts par baseSize (les 3 speeds partagent probablement le même material avec rayon différent ; à extraire task #13) -- Pattern statique simple : spawn une fois, clear ensemble avec templates A/B -- En vector lines : 1 cercle par maxMoveTemplate, dessiné via setVectorLines + Physics.cast par segment pour drape relief -- Cleanup propre : `clearMovementTemplates` doit clear la table d'état globale spécifique au maxMove de cet Order Token -- **Templates A/B (mesh) restent inchangés** — pas dans le scope du refactor - -## Couleur attendue - -Le cercle Maximum Move devrait visuellement matcher la couleur du template de speed correspondant : -- Speed 1 : couleur 1 (vert ?) -- Speed 2 : couleur 2 (jaune ?) -- Speed 3 : couleur 3 (rouge ?) - -Note `templateInfo.moveTemplate[selectedSpeed].colorTint` — c'est le tint des templates A/B, mais le cercle Maximum Move utilise un bundle distinct sans colorTint (le bundle embarque sa propre couleur). À vérifier empiriquement / via UnityPy quelle est la couleur baked-in de chaque bundle Maximum Move par speed. diff --git a/mac-patcher/retro-range.md b/mac-patcher/retro-range.md deleted file mode 100644 index a4569069..00000000 --- a/mac-patcher/retro-range.md +++ /dev/null @@ -1,86 +0,0 @@ -# Rétro-ingé Range Ruler - -## Visuel attendu - -12 tailles dans `getRangeRulerLinks()` (`mod/src/includes/data/RangeRulerLinks.ttslua`) : - -| Clé | Cible | Range | -|---|---|---| -| `small` | base 27mm | range mini | -| `medium` | base 50mm | range mini | -| `large` | base 70mm | range mini | -| `huge` | base 100mm | range véhicule | -| `laat` | base 120mm | range LAAT | -| `epic` | base 150mm | range épique | -| `long` | base 100mm oblong | range véhicule long | -| `snail` | base 100x200mm | range escargot | -| `bombCart` | bomb cart | range spécifique | -| `smokeToken` | jeton fumée 18.8mm | range 1 | -| `token` | jeton charge/objectif/condition 25.1mm | range 1 | -| `tokenRangeTwo` | jeton graffiti 25.1mm | range 2 | -| `poi` | POI token 50.8mm | range 0.5 (3") | - -Rendu côté Windows : **4 cercles concentriques** projetés au sol via Unity Projector legacy (1 bundle = N Projectors embarqués pour les bandes de portée 1/2/3/4). Couleurs/alphas/rayons par bande **à extraire via UnityPy** (task #13). - -**Configurations par overlay** : -- Rulers de fig (small/med/large/huge/laat/epic/long/snail) : **4 cercles** (range 1/2/3/4) -- `smokeToken` : 1 cercle (range 1, effet smoke 1 pouce) -- `token` (charge/obj/cond) : 1 cercle (range 1) -- `tokenRangeTwo` (graffiti) : 2 cercles (range 1 + 2) -- `poi` : 1 cercle (range 0.5 = 3") -- `bombCart` : à confirmer empiriquement - -## Déclencheurs - -### Spawn -1. **Hotkey "Show Range On Hovered Model"** — `StarWarsLegion.lua:601-608` → `showRangeOnHoveredModel(hoverObject)` -2. **Bouton "RANGE" sur Order Token** — `Order_Token.a57c41.lua:354-365` → `targetingMode()` ligne 922 → `spawnRangeRuler(selectedUnitObj)` -3. **Bouton "RANGE" via attackMode** — `Order_Token.a57c41.lua:934` → `spawnRangeRuler(selectedUnitObj)` -4. **Bouton "R" sur POI Token** — `POI_Tokens/POI_Token.761483.lua:23,41-46` → `toggleRangeRuler()` → `spawnTokenRangeRuler()` → `spawnRangeRuler(self, tokenRulerBundle)` avec override -5. **Bouton "R" sur autres tokens** — `includes/TokenWithRangeRuler.ttslua:37-43,52-56` (smokeToken, token, tokenRangeTwo via `rangeKey` scopé par-token) - -### Clear -1. **Hotkey re-press** — toggle via `showRangeOnHoveredModel` -2. **`clearRangeRulers()` (pluriel)** — Order Token : appelé par `clearTemplates`, `exitTargetingMode`, `exitAttackMode`, `attackMenu` -3. **`clearRangeRuler()` (singulier)** — POI/Token : appelé par `toggleRangeRuler` et `onDestroy` -4. **`standbyTokens()` global** — `StarWarsLegion.lua:567` — destroy tout objet nommé `"Range Ruler"` -5. **`removeLockedRulers()`** — `GAME_CONTROLLER.623b03.lua:151` — destroy par nom - -## Différence clé vs Cohesion : suivi temps réel - -`RangeRulers.ttslua:78-79` : - -```lua -luaScript = "targetGUID = '"..rangeSourceObject.getGUID().."'\n" - .. "function onFixedUpdate()\n" - .. " if targetGUID ~= nil then\n" - .. " targetObj = getObjectFromGUID(targetGUID)\n" - .. " local targetPosition = targetObj.getPosition()\n" - .. " self.setPosition({targetPosition.x, targetPosition.y + 20, targetPosition.z})\n" - .. " self.setRotation({0,targetObj.getRotation().y,0})\n" - .. " end\n" - .. "end" -rangeRuler.setLuaScript(luaScript) -``` - -→ Le ruler **se repositionne 60 fois/sec** côté objet ruler (pas côté Lua de la fig). Différent de Cohesion qui est statique. - -**Implication refactor** : pour Range, le suivi temps réel est utile gameplay (tu mesures pendant que tu déplaces la fig pour évaluer si tu vas être en range). On ne peut pas simplement passer en pur event-driven sans perte UX. Le drape sur relief recalculé doit suivre — soit redraw à chaque tick visible (avec mémoization de position pour éviter le leak), soit accepter une dégradation UX (redraw au drop seulement). - -## État scopé (même problème que Cohesion §5) - -Variable `rangeRuler` scopée par script-objet (Global, chaque fig leader, chaque POI Token, chaque token générique). Pattern identique à Cohesion : 3+ scopes peuvent désaccorder. - -`selectedUnitObj` global aussi tracké côté Range (`showRangeOnHoveredModel` ligne 7-17 de RangeRulers.ttslua). - -## Override bundle (paramètre `projectorBundleOverride`) - -`spawnRangeRuler(rangeSourceObject, projectorBundleOverride)` — le 2e paramètre permet de fournir une URL bundle au lieu de la lookuper via `unitData.baseSize`. Utilisé par POI Token et TokenWithRangeRuler qui passent `rangeRulerTable[rangeKey]` directement. À conserver dans le refactor. - -## Implications refactor - -- API publique à conserver : `showRangeOnHoveredModel`, `spawnRangeRuler` (avec override), `clearRangeRulers` (Order Token), `clearRangeRuler` (POI/Token) -- 12 bundle URLs → 12 sets de params visuels à extraire (chacun avec **1 à 4 cercles concentriques** selon le type) et reproduire en vector lines (task #13) -- Pattern suivi temps réel → décision design (redraw par tick avec mémoization vs redraw event-driven uniquement) -- Filtres cleanup global (`standbyTokens`, `removeLockedRulers`) à adapter pour la table d'état -- Override `projectorBundleOverride` → s'inscrit naturellement dans une table d'état `{fig=obj, rangeKey="poi"}` ou similaire diff --git a/mac-patcher/scan_bundles.py b/mac-patcher/scan_bundles.py deleted file mode 100644 index eb5edfd2..00000000 --- a/mac-patcher/scan_bundles.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -"""Scan TTS AssetBundles for material -> shader references.""" -import UnityPy -import os -import sys -from collections import defaultdict, Counter - -BUNDLE_DIR = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles/") -STOCK_PREFIXES = ( - "Standard", - "Hidden/", - "Mobile/", - "Particles/", - "UI/", - "Sprites/", - "Skybox/", - "Legacy Shaders/", - "Universal Render Pipeline/", - "URP/", - "Unlit/", - "GUI/", - "Lightmap", - "VertexLit", - "Reflective/", - "Self-Illumin/", - "Transparent/", - "FX/", - "Nature/", - "TextMeshPro/", - "TextMesh Pro/", -) - -def is_stock(name: str) -> bool: - return any(name.startswith(p) for p in STOCK_PREFIXES) - -shader_to_bundles = defaultdict(set) -shader_material_count = Counter() -bundle_to_shaders = defaultdict(set) -embedded_shaders_per_bundle = defaultdict(set) -errors = [] -bundle_summary = [] - -bundles = sorted(f for f in os.listdir(BUNDLE_DIR) if f.endswith(".unity3d")) -print(f"Scanning {len(bundles)} bundles in {BUNDLE_DIR}\n", file=sys.stderr) - -for fname in bundles: - path = os.path.join(BUNDLE_DIR, fname) - short_id = fname.replace("httpssteamusercontentaakamaihdnetugc", "")[:20] - fsize = os.path.getsize(path) - n_mat = 0 - n_shader = 0 - n_tex = 0 - try: - env = UnityPy.load(path) - for obj in env.objects: - t = obj.type.name - if t == "Material": - n_mat += 1 - try: - data = obj.read() - shader_name = None - try: - sref = data.m_Shader - # Try multiple ways to resolve PPtr - try: - shader_obj = sref.deref() - except Exception: - shader_obj = None - if shader_obj is None: - try: - shader_obj_read = sref.read() - if shader_obj_read is not None: - shader_name = ( - getattr(getattr(shader_obj_read, "m_ParsedForm", None), "m_Name", None) - or getattr(shader_obj_read, "m_Name", None) - ) - except Exception: - pass - else: - sd = shader_obj.read() - shader_name = ( - getattr(getattr(sd, "m_ParsedForm", None), "m_Name", None) - or getattr(sd, "m_Name", None) - ) - except Exception as e: - errors.append(f"{short_id} mat shader resolve: {e!r}") - if not shader_name: - # PPtr fields - try: - file_id = getattr(data.m_Shader, "file_id", "?") - path_id = getattr(data.m_Shader, "path_id", "?") - shader_name = f"" - except Exception: - shader_name = "" - # Material name - mat_name = getattr(data, "m_Name", "") or "" - shader_to_bundles[shader_name].add(short_id) - shader_material_count[shader_name] += 1 - bundle_to_shaders[short_id].add((shader_name, mat_name)) - except Exception as e: - errors.append(f"{short_id} mat read err: {e!r}") - elif t == "Shader": - n_shader += 1 - try: - data = obj.read() - sname = ( - getattr(getattr(data, "m_ParsedForm", None), "m_Name", None) - or getattr(data, "m_Name", None) - ) - if sname: - embedded_shaders_per_bundle[short_id].add(sname) - except Exception as e: - errors.append(f"{short_id} shader read: {e!r}") - elif t in ("Texture2D", "Texture"): - n_tex += 1 - except Exception as e: - errors.append(f"{short_id}: load failed: {e!r}") - - bundle_summary.append((short_id, fsize, n_mat, n_shader, n_tex)) - -# === REPORT === -print("=" * 90) -print("BUNDLE SUMMARY (id_prefix, size_bytes, n_materials, n_shaders_embedded, n_textures)") -print("=" * 90) -for short_id, fsize, n_mat, n_shader, n_tex in bundle_summary: - print(f" {short_id} {fsize:>10} mats={n_mat:>3} shaders={n_shader:>2} tex={n_tex:>3}") - -print() -print("=" * 90) -print("SHADER FREQUENCY ACROSS ALL MATERIALS (sorted by usage)") -print("=" * 90) -for sname, count in shader_material_count.most_common(): - flag = " [CUSTOM?]" if not is_stock(sname) and not sname.startswith("4} mats / {n_bundles:>2} bundles {sname}{flag}") - -print() -print("=" * 90) -print("EMBEDDED SHADERS (shaders bundled INSIDE a .unity3d, sorted)") -print("=" * 90) -all_embedded = set() -for shaders in embedded_shaders_per_bundle.values(): - all_embedded.update(shaders) -print(f"\nDistinct embedded shaders: {len(all_embedded)}") -for s in sorted(all_embedded): - bundles_with = [b for b, ss in embedded_shaders_per_bundle.items() if s in ss] - print(f" - {s} ({len(bundles_with)} bundle(s): {','.join(bundles_with[:3])}{'...' if len(bundles_with) > 3 else ''})") - -print() -print("=" * 90) -print("BUNDLES REFERENCING NON-STOCK SHADERS (with material names)") -print("=" * 90) -for short_id, items in sorted(bundle_to_shaders.items()): - custom = [(s, m) for (s, m) in items if not is_stock(s) and not s.startswith(" "mac" | "windows" -deploymentMode = deploymentMode or "mac" -- table-wide setting - -function gGetMode(params) - return playerOverlayMode[params.color] or "mac" -end - -function gGetDeploymentMode() - return deploymentMode -end - -function gToggleDeploymentMode() - deploymentMode = (deploymentMode == "mac") and "windows" or "mac" - macRefreshModeUI() -end - -function gCohesionTrigger(params) - if not params or not params.figGUID then return end - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "mac" - if mode == "windows" then - -- Object may not have the original alias (e.g., hovered non-fig object). - -- pcall guards against "no such function" errors that would otherwise - -- propagate out of the hotkey/button callback. - local ok = pcall(function() fig.call("spawnCohesionRulerOriginal", fig) end) - if not ok then gToggleCohesion({figGUID = params.figGUID}) end - else - gToggleCohesion({figGUID = params.figGUID}) - end -end - -function gRangeTrigger(params) - if not params or not params.figGUID then return end - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "mac" - if mode == "windows" then - local ok = pcall(function() fig.call("spawnRangeRulerOriginal", fig) end) - if not ok then gToggleRange({figGUID = params.figGUID}) end - else - gToggleRange({figGUID = params.figGUID}) - end -end - --- UI: floating panel with one toggle button per seated player -function macModeClick(player, _, id) - local color = id:sub(9) -- "macmode_Red" -> "Red" - if player.color ~= color then - broadcastToColor("Only the player at this seat can toggle their mode.", - player.color, {1, 0.5, 0.5}) - return - end - local cur = playerOverlayMode[color] or "mac" - playerOverlayMode[color] = (cur == "mac") and "windows" or "mac" - -- Wipe any active Cohesion/Range overlays for this player so the next - -- trigger redraws in the chosen mode without stale visuals. - macRefreshModeUI() -end - -local function macFindNodeById(tree, id) - for _, node in ipairs(tree) do - if node.attributes and node.attributes.id == id then return node, tree end - if node.children then - local found, parent = macFindNodeById(node.children, id) - if found then return found, parent end - end - end - return nil, nil -end - --- Tracks the panel's desired active state across refreshes. Persists so a --- click on the close X before the initial refresh is honored. -macModePanelActive = (macModePanelActive == nil) and true or macModePanelActive - -function macModeToggleVisibility() - macModePanelActive = not macModePanelActive - local tree = UI.getXmlTable() or {} - local panel = macFindNodeById(tree, "macModePanel") - if panel then - panel.attributes.active = macModePanelActive and "true" or "false" - UI.setXmlTable(tree) - else - macRefreshModeUI() - end -end - -function macRefreshModeUI() - local seated = Player.getPlayers() - local rows = {{ - tag = "Panel", - attributes = { - color = "transparent", - preferredHeight = "24", - }, - children = { - { - tag = "Text", - attributes = { - text = "Mac TTS U6 Patch", - fontSize = "16", - color = "white", - alignment = "MiddleLeft", - rectAlignment = "MiddleLeft", - }, - }, - { - tag = "Button", - attributes = { - id = "macModePanelClose", - text = "X", - onClick = "macModeToggleVisibility", - color = "#3a1e1e", - textColor = "white", - fontSize = "12", - width = "24", - height = "20", - rectAlignment = "MiddleRight", - }, - }, - }, - }, { - tag = "Text", - attributes = { - text = "Cohesion & Range: pick your renderer", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }} - if #seated == 0 then - table.insert(rows, { - tag = "Text", - attributes = { - text = "(no seated players)", - fontSize = "12", - color = "#888888", - alignment = "MiddleCenter", - }, - }) - end - for _, p in ipairs(seated) do - local mode = playerOverlayMode[p.color] or "mac" - local label = p.color .. " - " .. - (mode == "mac" and "MAC FALLBACK" or "WINDOWS ORIGINAL") - local bg = (mode == "mac") and "#1e7a3a" or "#7a3a1e" - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_" .. p.color, - text = label, - onClick = "macModeClick", - color = bg, - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - end - -- Deployment is table-wide (everyone sees the same zones); use a single - -- global toggle rather than per-seat. - table.insert(rows, { - tag = "Text", - attributes = { - text = "Deployment (table-wide)", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }) - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_deployment", - text = (deploymentMode == "mac") and "DEPLOYMENT: MAC FALLBACK" - or "DEPLOYMENT: WINDOWS ORIGINAL", - onClick = "macDeploymentClick", - color = (deploymentMode == "mac") and "#1e3a7a" or "#7a3a1e", - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - -- Build my panel (will be merged into the existing UI tree below so we - -- don't clobber legionFloatingMenu / Welcome / Chess Clocks etc.). - local panel = { - tag = "Panel", - attributes = { - id = "macModePanel", - active = macModePanelActive and "true" or "false", - rectAlignment = "MiddleRight", - offsetXY = "-10 80", - width = "260", - height = "320", - color = "rgba(0.06,0.06,0.06,0.9)", - padding = "8 8 8 8", - outlineSize = "1 1", - outline = "#303030", - }, - children = {{ - tag = "VerticalLayout", - attributes = { - spacing = "4", - childForceExpandHeight = "false", - childForceExpandWidth = "true", - }, - children = rows, - }}, - } - - local tree = UI.getXmlTable() or {} - -- Remove any stale macModePanel before reinserting the fresh one. - for i = #tree, 1, -1 do - if tree[i].attributes and tree[i].attributes.id == "macModePanel" then - table.remove(tree, i) - end - end - table.insert(tree, panel) - - -- Inject a "Mac Patch" button into the bottom-right legionFloatingMenu - -- (replaces the first interactable=false placeholder button). - local menu = macFindNodeById(tree, "legionFloatingMenu") - if menu and menu.children then - local hasMine = false - for _, c in ipairs(menu.children) do - if c.attributes and c.attributes.id == "macModeMenuButton" then - hasMine = true; break - end - end - if not hasMine then - for i, c in ipairs(menu.children) do - if c.attributes and c.attributes.interactable == "false" then - menu.children[i] = { - tag = "Button", - attributes = { - id = "macModeMenuButton", - fontSize = "10", - onClick = "macModeToggleVisibility", - tooltip = "Toggle Mac TTS U6 Patch panel", - }, - value = "Mac Patch", - } - break - end - end - end - end - - UI.setXmlTable(tree) -end - -function macDeploymentClick(_, _, _) - gToggleDeploymentMode() -end - -function onPlayerChangeColor(_) macRefreshModeUI() end -function onPlayerConnect(_) macRefreshModeUI() end -function onPlayerDisconnect(_) macRefreshModeUI() end - -Wait.time(macRefreshModeUI, 2) - --- Override hotkey init functions to capture playerColor and route through --- the per-seat trigger. Defining initCohesionHotkeys/initRangebandHotkeys --- here SHADOWS the originals - when the original onLoad runs init*(), our --- versions register the hotkey instead. - -function initCohesionHotkeys() - addHotkey("Show Cohesion On Hovered Model", - function(playerColor, hoverObject, _) - if not hoverObject or not hoverObject.interactable then return end - gCohesionTrigger({ - figGUID = hoverObject.getGUID(), - playerColor = playerColor, - }) - end) -end - -function initRangebandHotkeys() - addHotkey("Show Range On Hovered Model", - function(playerColor, hoverObject, _) - if not hoverObject or not hoverObject.interactable then return end - gRangeTrigger({ - figGUID = hoverObject.getGUID(), - playerColor = playerColor, - }) - end) -end - --- Legacy global-scope wrappers (for any code that still calls these directly). --- Default to mac mode when no color context is available. -function showCohesionOnHoveredModel(hoverObject) - if not hoverObject or not hoverObject.interactable then return end - gToggleCohesion({ figGUID = hoverObject.getGUID() }) -end - -function spawnCohesionRuler(cohesionSourceObject) - if not cohesionSourceObject then return end - gSpawnCohesion({ figGUID = cohesionSourceObject.getGUID() }) -end - -function clearCohesionRuler() - for key, entry in pairs(activeOverlays) do - if entry.type == "cohesion" then activeOverlays[key] = nil end - end - macRedrawAll() -end - -function showRangeOnHoveredModel(hoverObject) - if not hoverObject or not hoverObject.interactable then return end - gToggleRange({ figGUID = hoverObject.getGUID() }) -end - -function spawnRangeRuler(rangeSourceObject, _) - if not rangeSourceObject then return end - gSpawnRange({ figGUID = rangeSourceObject.getGUID() }) -end - -function clearRangeRulers() - gClearAllRange() -end - diff --git a/mod/src/includes/RangeRulers.ttslua b/mod/src/includes/RangeRulers.ttslua index c51676e0..6df6e15a 100644 --- a/mod/src/includes/RangeRulers.ttslua +++ b/mod/src/includes/RangeRulers.ttslua @@ -80,33 +80,3 @@ function spawnRangeRuler(rangeSourceObject, projectorBundleOverride) noRulers = false end - --- ============================================ --- Mac fallback per-seat router (appended). --- Mirrors the Cohesion pattern. See !/Overlays for the manager. --- ============================================ -spawnRangeRulerOriginal = spawnRangeRuler -clearRangeRulersOriginal = clearRangeRulers - -function spawnRangeRuler(rangeSourceObject, _) - if not rangeSourceObject then return end - Global.call("gRangeTrigger", { - figGUID = rangeSourceObject.getGUID(), - playerColor = nil, - }) -end - -function clearRangeRulers() - Global.call("gClearAllRange", {}) - -- Also run the original (destroys the bundle Object spawned in Windows - -- mode). Skip if rangeRuler was never set (pure-Mac session). - if rangeRuler ~= nil then - pcall(clearRangeRulersOriginal) - end -end - -function clearRangeRuler() - if self and self.getGUID and self.getGUID() ~= "-1" then - Global.call("gClearRange", { figGUID = self.getGUID() }) - end -end diff --git a/src/index.ts b/src/index.ts index ba63a1e4..45d99efd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,31 +103,25 @@ export async function compileSaveFile( } } -function defaultTTSHomeDir(): string { - const platform = os.platform(); - if (platform === 'win32') { - return steam.homeDir.win32(process.env); - } - if (platform === 'darwin') { - return path.join(os.homedir(), 'Library', 'Tabletop Simulator'); - } - if (platform === 'linux') { - return path.join(os.homedir(), '.local', 'share', 'Tabletop Simulator'); - } - throw new Error(`Unsupported platform: ${platform}`); -} - export async function destroySymlink(homeDir?: string): Promise { + // TODO: Add non-win32 support. if (!homeDir) { - homeDir = defaultTTSHomeDir(); + if (os.platform() !== 'win32') { + throw new Error(`Unsupported platform: ${os.platform()}`); + } + homeDir = steam.homeDir.win32(process.env); } const from = path.join(homeDir, 'Saves', 'TTSDevLink'); return fs.remove(from); } export async function createSymlink(homeDir?: string): Promise { + // TODO: Add non-win32 support. if (!homeDir) { - homeDir = defaultTTSHomeDir(); + if (os.platform() !== 'win32') { + throw new Error(`Unsupported platform: ${os.platform()}`); + } + homeDir = steam.homeDir.win32(process.env); } await destroySymlink(homeDir); const from = path.join(homeDir, 'Saves', 'TTSDevLink');