Some dits

This commit is contained in:
dazedanon 2026-02-16 08:42:46 -06:00
parent 0f7ee78a88
commit 696ee730a6
3 changed files with 490 additions and 2 deletions

View file

@ -162,6 +162,9 @@ def parseJSON(data, filename):
# PSData format (Nupu_PSData.json - MailDatas, MemoryData, ShopDatas, hStData)
elif any(k in data for k in ["MailDatas", "MemoryData", "ShopDatas", "hStData"]):
result = translatePSData(data, filename)
# RdData format (RdData.json - dgnDatas with dungeon/dialogue data)
elif "dgnDatas" in data:
result = translateRdData(data, filename)
else:
result = translateJSON(data, filename, [])
else:
@ -471,6 +474,258 @@ def translatePSData(data, filename):
return tokens
def translateRdData(data, filename):
"""Translate RdData JSON format (e.g. RdData.json).
Handles dungeon data with nested dialogue, choices, and UI text.
Only translates player-visible text fields.
"""
global LOCK, ESTIMATE, FILENAME, PBAR, MISMATCH
tokens = [0, 0]
def batchTranslate(stringList, context):
"""Translate a list of strings and return translations. Returns [] on mismatch."""
nonlocal tokens
if not stringList:
return []
response = translateAI(stringList, context, True)
tokens[0] += response[1][0]
tokens[1] += response[1][1]
if len(stringList) != len(response[0]):
with LOCK:
if FILENAME not in MISMATCH:
MISMATCH.append(FILENAME)
return []
return response[0]
if "dgnDatas" not in data:
return tokens
dgns = data["dgnDatas"]
# ================================================================
# PASS 1: Collect all translatable strings
# ================================================================
# -- Dungeon-level short fields --
dgnNameS, dgnNameI = [], []
clearTxtS, clearTxtI = [], []
hosyuTxtS, hosyuTxtI = [], []
firstHosyuTxtS, firstHosyuTxtI = [], []
memoTxtS, memoTxtI = [], []
appMemoTxtS, appMemoTxtI = [], []
areaMemoS, areaMemoI = [], []
# -- Dungeon-level long text fields --
naiyoTxtS, naiyoTxtI = [], []
naiyoTxtExS, naiyoTxtExI = [], []
johoTxtS, johoTxtI = [], []
johoTxtExS, johoTxtExI = [], []
# -- Floor names --
kaisoNameS, kaisoNameI = [], [] # (dgnIdx, kaisoIdx)
# -- Dialogue (talkDatas / talkDatasEx) --
talkNameS, talkNameI = [], [] # (dgnIdx, talkKey, talkIdx)
speakerNameS, speakerNameI = [], [] # (dgnIdx, talkKey, talkIdx, speechIdx)
dialogueS, dialogueI = [], [] # (dgnIdx, talkKey, talkIdx, speechIdx)
# -- Choice events (sijiDatas) --
sijiNameS, sijiNameI = [], [] # (dgnIdx, sijiIdx)
sijiChoiceS, sijiChoiceI = [], [] # (dgnIdx, sijiIdx)
for di, dgn in enumerate(dgns):
if dgn is None:
continue
# Dungeon short fields
if dgn.get("name") and re.search(LANGREGEX, dgn["name"]):
dgnNameS.append(dgn["name"])
dgnNameI.append(di)
if dgn.get("clearTxt") and re.search(LANGREGEX, dgn["clearTxt"]):
clearTxtS.append(dgn["clearTxt"])
clearTxtI.append(di)
if dgn.get("hosyuTxt") and re.search(LANGREGEX, dgn["hosyuTxt"]):
hosyuTxtS.append(dgn["hosyuTxt"])
hosyuTxtI.append(di)
if dgn.get("firstHosyuTxt") and re.search(LANGREGEX, dgn["firstHosyuTxt"]):
firstHosyuTxtS.append(dgn["firstHosyuTxt"])
firstHosyuTxtI.append(di)
if dgn.get("memoTxt") and re.search(LANGREGEX, dgn["memoTxt"]):
memoTxtS.append(dgn["memoTxt"])
memoTxtI.append(di)
if dgn.get("appMemoTxt") and re.search(LANGREGEX, dgn["appMemoTxt"]):
appMemoTxtS.append(dgn["appMemoTxt"])
appMemoTxtI.append(di)
if dgn.get("areaMemo") and re.search(LANGREGEX, dgn["areaMemo"]):
areaMemoS.append(dgn["areaMemo"])
areaMemoI.append(di)
# Dungeon long text fields (strip line breaks for translation)
if dgn.get("naiyoTxt") and re.search(LANGREGEX, dgn["naiyoTxt"]):
naiyoTxtS.append(dgn["naiyoTxt"].replace("\r\n", " ").replace("\n", " ").strip())
naiyoTxtI.append(di)
if dgn.get("naiyoTxtEx") and re.search(LANGREGEX, dgn["naiyoTxtEx"]):
naiyoTxtExS.append(dgn["naiyoTxtEx"].replace("\r\n", " ").replace("\n", " ").strip())
naiyoTxtExI.append(di)
if dgn.get("johoTxt") and re.search(LANGREGEX, dgn["johoTxt"]):
johoTxtS.append(dgn["johoTxt"].replace("\r\n", " ").replace("\n", " ").strip())
johoTxtI.append(di)
if dgn.get("johoTxtEx") and re.search(LANGREGEX, dgn["johoTxtEx"]):
johoTxtExS.append(dgn["johoTxtEx"].replace("\r\n", " ").replace("\n", " ").strip())
johoTxtExI.append(di)
# Floor names
if "kaisoDatas" in dgn and isinstance(dgn["kaisoDatas"], list):
for ki, kaiso in enumerate(dgn["kaisoDatas"]):
if isinstance(kaiso, dict) and kaiso.get("kaisoName") and re.search(LANGREGEX, kaiso["kaisoName"]):
kaisoNameS.append(kaiso["kaisoName"])
kaisoNameI.append((di, ki))
# Dialogue from talkDatas and talkDatasEx
for talkKey in ["talkDatas", "talkDatasEx"]:
if talkKey in dgn and isinstance(dgn[talkKey], list):
for ti, talk in enumerate(dgn[talkKey]):
if not isinstance(talk, dict):
continue
if talk.get("name") and re.search(LANGREGEX, talk["name"]):
talkNameS.append(talk["name"])
talkNameI.append((di, talkKey, ti))
if "speachDatas" in talk and isinstance(talk["speachDatas"], list):
for si, speech in enumerate(talk["speachDatas"]):
if not isinstance(speech, dict):
continue
if speech.get("name") and re.search(LANGREGEX, speech["name"]):
speakerNameS.append(speech["name"])
speakerNameI.append((di, talkKey, ti, si))
if speech.get("text") and re.search(LANGREGEX, speech["text"]):
dialogueS.append(speech["text"].replace("\r\n", " ").replace("\n", " ").strip())
dialogueI.append((di, talkKey, ti, si))
# Choice events (sijiDatas)
if "sijiDatas" in dgn and isinstance(dgn["sijiDatas"], list):
for si, siji in enumerate(dgn["sijiDatas"]):
if not isinstance(siji, dict):
continue
if siji.get("name") and re.search(LANGREGEX, siji["name"]):
sijiNameS.append(siji["name"])
sijiNameI.append((di, si))
if siji.get("choiceSijiTxt") and re.search(LANGREGEX, siji["choiceSijiTxt"]):
sijiChoiceS.append(siji["choiceSijiTxt"].replace("\r\n", " ").replace("\n", " ").strip())
sijiChoiceI.append((di, si))
# Set progress bar total
totalItems = (
len(dgnNameS) + len(clearTxtS) + len(hosyuTxtS) + len(firstHosyuTxtS)
+ len(memoTxtS) + len(appMemoTxtS) + len(areaMemoS)
+ len(naiyoTxtS) + len(naiyoTxtExS) + len(johoTxtS) + len(johoTxtExS)
+ len(kaisoNameS)
+ len(talkNameS) + len(speakerNameS) + len(dialogueS)
+ len(sijiNameS) + len(sijiChoiceS)
)
PBAR.total = totalItems
PBAR.refresh()
# ================================================================
# PASS 2: Translate each batch and apply results back to data
# ================================================================
# -- Dungeon short fields --
for tl, idx in zip(batchTranslate(dgnNameS, "Dungeon Name"), dgnNameI):
dgns[idx]["name"] = tl
if dgnNameS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(clearTxtS, "Clear Condition"), clearTxtI):
dgns[idx]["clearTxt"] = tl
if clearTxtS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(hosyuTxtS, "Reward Description"), hosyuTxtI):
dgns[idx]["hosyuTxt"] = tl
if hosyuTxtS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(firstHosyuTxtS, "First Clear Reward"), firstHosyuTxtI):
dgns[idx]["firstHosyuTxt"] = tl
if firstHosyuTxtS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(memoTxtS, "Memo Label"), memoTxtI):
dgns[idx]["memoTxt"] = tl
if memoTxtS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(appMemoTxtS, "App Memo"), appMemoTxtI):
dgns[idx]["appMemoTxt"] = tl
if appMemoTxtS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(areaMemoS, "Area Label"), areaMemoI):
dgns[idx]["areaMemo"] = tl
if areaMemoS:
save_progress_json(data, filename)
# -- Dungeon long text fields (with word wrap) --
for tl, idx in zip(batchTranslate(naiyoTxtS, "Mission Description"), naiyoTxtI):
dgns[idx]["naiyoTxt"] = dazedwrap.wrapText(tl, WIDTH)
if naiyoTxtS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(naiyoTxtExS, "Extended Mission Description"), naiyoTxtExI):
dgns[idx]["naiyoTxtEx"] = dazedwrap.wrapText(tl, WIDTH)
if naiyoTxtExS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(johoTxtS, "Info Text"), johoTxtI):
dgns[idx]["johoTxt"] = dazedwrap.wrapText(tl, WIDTH)
if johoTxtS:
save_progress_json(data, filename)
for tl, idx in zip(batchTranslate(johoTxtExS, "Extended Info Text"), johoTxtExI):
dgns[idx]["johoTxtEx"] = dazedwrap.wrapText(tl, WIDTH)
if johoTxtExS:
save_progress_json(data, filename)
# -- Floor names --
for tl, (di, ki) in zip(batchTranslate(kaisoNameS, "Floor Name"), kaisoNameI):
dgns[di]["kaisoDatas"][ki]["kaisoName"] = tl
if kaisoNameS:
save_progress_json(data, filename)
# -- Dialogue event names --
for tl, (di, tk, ti) in zip(batchTranslate(talkNameS, "Event Label"), talkNameI):
dgns[di][tk][ti]["name"] = tl
if talkNameS:
save_progress_json(data, filename)
# -- Speaker names --
for tl, (di, tk, ti, si) in zip(batchTranslate(speakerNameS, "Character Name"), speakerNameI):
dgns[di][tk][ti]["speachDatas"][si]["name"] = tl
if speakerNameS:
save_progress_json(data, filename)
# -- Dialogue text (with word wrap) --
for tl, (di, tk, ti, si) in zip(batchTranslate(dialogueS, "Dialogue"), dialogueI):
dgns[di][tk][ti]["speachDatas"][si]["text"] = dazedwrap.wrapText(tl, WIDTH)
if dialogueS:
save_progress_json(data, filename)
# -- Choice event names --
for tl, (di, si) in zip(batchTranslate(sijiNameS, "Event Name"), sijiNameI):
dgns[di]["sijiDatas"][si]["name"] = tl
if sijiNameS:
save_progress_json(data, filename)
# -- Choice text (with word wrap) --
for tl, (di, si) in zip(batchTranslate(sijiChoiceS, "Choice Event Text"), sijiChoiceI):
dgns[di]["sijiDatas"][si]["choiceSijiTxt"] = dazedwrap.wrapText(tl, WIDTH)
if sijiChoiceS:
save_progress_json(data, filename)
return tokens
def translateJSON(data, filename, translatedList):
global LOCK, ESTIMATE, FILENAME, PBAR, MISMATCH
if translatedList:

View file

@ -2543,13 +2543,14 @@ def searchCodes(page, pbar, jobList, filename):
# "gameVariables.setValue": (r'\$gameVariables\.setValue\(\d+,\s*"([^"]*)"\)', False),
# "BattleManager._logWindow.push('addText'": (r"BattleManager._logWindow.push\('addText',\s'(.+)'\)", False),
# "BattleManager._logWindow.addText": (r"BattleManager._logWindow.addText\('(.+)'\)", False),
"this.BLogAdd": (r'this\.BLogAdd\(.+?\\?"(.+?)\\?"\)', False),
# "this.BLogAdd": (r'this\.BLogAdd\?(.+?\\?"(.+?)\\?"\)', False),
# "Fuki_Set": (r'Fuki_Set\([\s,\d\w\W]+?"(.+?)",', False),
# "_EventSetting": (r'_EventSetting[\s,\d\w\W]+?"(.+?)";', False),
# "this.Menu_SexTxtSet(": (r'"(.+)"', True),
# "Rn_RsltTxtArr": (r'"(.+)"', True),
# "_章切り替えStart": (r'_章切り替えStart\(\s*\\?"(.+?)\\?"', False),
# "MobNameSet": (r'MobNameSet\(\\?"(.+?)\\?"\)', False),
"AddAddress": (r'AddAddress\(\d+,\s*\\?"(.+?)\\?"', False),
}
@ -2621,6 +2622,236 @@ def searchCodes(page, pbar, jobList, filename):
codeList[i]["parameters"][0] = jaString.replace(match.group(1), translatedText)
break
# AddCmnt handler - extract strings from array and name parameter
if "AddCmnt(" in jaString:
arrayMatch = re.search(r'AddCmnt\s*\(\s*\[(.+?)\]', jaString)
if arrayMatch:
arrayContent = arrayMatch.group(1)
strings = re.findall(r'\\?"(.+?)\\?"', arrayContent)
# Also grab the name argument after the array: , "name")
nameMatch = re.search(r'AddCmnt\s*\(\s*\[.+?\]\s*,\s*\\?"(.+?)\\?"\s*\)', jaString)
translatable = []
for s in strings:
if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]', s):
continue
if IGNORETLTEXT and not re.search(LANGREGEX, s):
continue
translatable.append(s)
# Translate and cache the speaker name via getSpeaker
nameStr = None
translatedName = ""
if nameMatch:
n = nameMatch.group(1)
if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]', n):
if not (IGNORETLTEXT and not re.search(LANGREGEX, n)):
nameStr = n
response = getSpeaker(n)
translatedName = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
if translatable or nameStr:
# Use the translated speaker name for context prefix
speakerPrefix = translatedName if translatedName else ""
if setData:
textHistory.append('"These comments are always about Romasha or her squad"')
for s in translatable:
if speakerPrefix:
list355655.append(f"[{speakerPrefix}]: {s}")
else:
list355655.append(s)
else:
for s in translatable:
if len(list355655) > 0:
translatedText = list355655[0]
list355655.pop(0)
# Strip speaker prefix if present
translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText)
# Replace double quotes to avoid breaking the JSON/JS syntax
translatedText = translatedText.replace('\\"', "'")
translatedText = translatedText.replace('"', "'")
jaString = jaString.replace(s, translatedText, 1)
# Replace the speaker name directly (already translated via getSpeaker)
if nameStr and translatedName:
translatedName = translatedName.replace('\\"', "'")
translatedName = translatedName.replace('"', "'")
jaString = jaString.replace(nameStr, translatedName, 1)
codeList[i]["parameters"][0] = jaString
# AddMaill handler - translate sender name (3rd quoted arg) and title (4th quoted arg)
# Example: this.AddMaill("M_IcoMail","liliy","リリィ","お得なクーポン配布",_MTxt,[24],193,true,504,1)
if "AddMaill(" in jaString:
# Extract all quoted strings in order
allQuoted = re.findall(r'\\?"([^"]*?)\\?"', jaString)
# args: [0]=icon, [1]=id, [2]=sender, [3]=title, ...
translatable = []
translatableIndices = []
for idx in [2, 3]:
if idx < len(allQuoted):
s = allQuoted[idx]
if not s.strip():
continue
if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]', s):
continue
if IGNORETLTEXT and not re.search(LANGREGEX, s):
continue
translatable.append(s)
translatableIndices.append(idx)
if translatable:
if setData:
for s in translatable:
list355655.append(s)
else:
for s in translatable:
if len(list355655) > 0:
translatedText = list355655[0]
list355655.pop(0)
translatedText = translatedText.replace('\\"', "'")
translatedText = translatedText.replace('"', "'")
jaString = jaString.replace(s, translatedText, 1)
codeList[i]["parameters"][0] = jaString
# # AddBbs handler - translate arrays of posts/replies, username, and location
# # Example: AddBbs(["この開発したパッチを..."], "コンピューターおじいちゃん","場所:猪鹿蝶",["良きパッチが..."],"patch_npc")
# if "AddBbs(" in jaString:
# translatable = []
# # Extract strings from the first array (topic posts)
# # Anchor with ],\s*\\?" after ] to skip past inner brackets like \\C[3]
# firstArrayMatch = re.search(r'AddBbs\s*\(\s*\[(.+?)\]\s*,\s*\\?"', jaString)
# firstArrayStrings = []
# if firstArrayMatch:
# firstArrayStrings = re.findall(r'\\?"([^"]+?)\\?"', firstArrayMatch.group(1))
# for s in firstArrayStrings:
# if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]', s):
# continue
# if IGNORETLTEXT and not re.search(LANGREGEX, s):
# continue
# translatable.append(s)
# # After the first array, extract: "username","location",["replies"],"picture_id"
# afterFirstArray = re.search(r'AddBbs\s*\(\s*\[.+?\]\s*,\s*(.*)\)\s*;?\s*$', jaString)
# nameStr = None
# translatedName = ""
# locationStr = None
# secondArrayStrings = []
# if afterFirstArray:
# rest = afterFirstArray.group(1)
# # Username (first quoted string after the array)
# nameMatch = re.match(r'\s*\\?"([^"]+?)\\?"', rest)
# if nameMatch:
# n = nameMatch.group(1)
# if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]', n):
# if not (IGNORETLTEXT and not re.search(LANGREGEX, n)):
# nameStr = n
# response = getSpeaker(n)
# translatedName = response[0]
# totalTokens[0] += response[1][0]
# totalTokens[1] += response[1][1]
# # Location (second quoted string after array, before second array)
# locMatch = re.match(r'\s*\\?"[^"]*?\\?"\s*,\s*\\?"([^"]+?)\\?"', rest)
# if locMatch:
# loc = locMatch.group(1)
# if re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]', loc):
# if not (IGNORETLTEXT and not re.search(LANGREGEX, loc)):
# locationStr = loc
# translatable.append(loc)
# # Second array (replies)
# # Anchor with ],\s*\\?" after ] to skip past inner brackets like \\C[3]
# secondArrayMatch = re.search(r',\s*\[(.+?)\]\s*,\s*\\?"', rest)
# if secondArrayMatch:
# secondArrayStrings = re.findall(r'\\?"([^"]+?)\\?"', secondArrayMatch.group(1))
# for s in secondArrayStrings:
# if not re.search(r'[a-zA-Z一-龠ぁ-ゔァ-ヴーa---\uFF61-\uFF9F]', s):
# continue
# if IGNORETLTEXT and not re.search(LANGREGEX, s):
# continue
# translatable.append(s)
# if translatable or nameStr:
# speakerPrefix = translatedName if translatedName else ""
# if setData:
# for s in translatable:
# if speakerPrefix:
# list355655.append(f"[{speakerPrefix}]: {s}")
# else:
# list355655.append(s)
# else:
# for s in translatable:
# if len(list355655) > 0:
# translatedText = list355655[0]
# list355655.pop(0)
# translatedText = re.sub(r'^\[.*?\]\s*[|:]\s*', '', translatedText)
# translatedText = translatedText.replace('\\"', "'")
# translatedText = translatedText.replace('"', "'")
# jaString = jaString.replace(s, translatedText, 1)
# # Replace the username directly (already translated via getSpeaker)
# if nameStr and translatedName:
# translatedName = translatedName.replace('\\"', "'")
# translatedName = translatedName.replace('"', "'")
# jaString = jaString.replace(nameStr, translatedName, 1)
# # Normalize \\C and \\N codes to always have exactly 4 backslashes
# jaString = re.sub(r'\\+([cCnN]\[\d+\])', r'\\\\\1', jaString)
# codeList[i]["parameters"][0] = jaString
# _MTxt handler - translates var _MTxt = "text" + "\n"; across 355 + 655 lines
# Code 355: var _MTxt = "text" + "\n";
# Code 655: _MTxt += "text" + "\n";
if "_MTxt" in jaString and codeList[i]["code"] == 355:
mtxtRegex = r'"(.+?)"\s*\+\s*"\\n"'
textLines = []
textLineIndices = []
# Extract text from the 355 line itself
match355 = re.search(mtxtRegex, jaString)
if match355:
text = match355.group(1)
if not (IGNORETLTEXT and not re.search(LANGREGEX, text)):
textLines.append(text)
textLineIndices.append(i)
# Extract text from subsequent 655 lines
j = i + 1
while j < len(codeList) and codeList[j]["code"] == 655:
param = codeList[j]["parameters"][0] if codeList[j]["parameters"] else ""
if "_MTxt" in param:
textMatch = re.search(mtxtRegex, param)
if textMatch:
text = textMatch.group(1)
if not (IGNORETLTEXT and not re.search(LANGREGEX, text)):
textLines.append(text)
textLineIndices.append(j)
j += 1
if textLines:
if setData:
for text in textLines:
list355655.append(text)
else:
for lineIdx in textLineIndices:
if len(list355655) > 0:
translatedText = list355655[0]
list355655.pop(0)
translatedText = translatedText.replace('\\"', "'")
translatedText = translatedText.replace('"', "'")
origParam = codeList[lineIdx]["parameters"][0]
origMatch = re.search(mtxtRegex, origParam)
if origMatch:
codeList[lineIdx]["parameters"][0] = origParam.replace(origMatch.group(1), translatedText)
i = j - 1
## Event Code: 408 (Script)
if "code" in codeList[i] and (codeList[i]["code"] == 408) and CODE408 is True:
# Only translate if preceded by a 108 with "選択肢ヘルプ" or another 408
@ -3741,6 +3972,7 @@ def getSpeaker(speaker: str):
except Exception:
pass
translated = response[0].title().replace("'S", "'s").replace("Speaker: ", "")
translated = re.sub(r'(\d)(St|Nd|Rd|Th)\b', lambda m: m.group(1) + m.group(2).lower(), translated)
if re.search(r"([a-zA-Z?])", translated) is None:
try:
@ -3757,6 +3989,7 @@ def getSpeaker(speaker: str):
except Exception:
pass
translated = response[0].title().replace("'S", "'s")
translated = re.sub(r'(\d)(St|Nd|Rd|Th)\b', lambda m: m.group(1) + m.group(2).lower(), translated)
with _speakerCacheLock:
if speaker not in _speakerCache:

View file

@ -25,7 +25,7 @@ You will be translating erotic and sexual content. I will provide you with lines
- Check every line to ensure all text inside is in English.
- `\\cself` is a variable for a string or number.
- Translate 'コイツ' as 'this bastard' or 'this bitch' depending on gender.
- Instead of translating a lone '回' as 'times' use 'x' instead so its shorter. Same with a lone '人'
- The `=` or `` characters in Japanese names acts as a double hyphen and is used to designate foreign names or nicknames. Surround the foreign or nickname in parenthesis. (e.g., `バンカー=ベット` → `Bunker (Bet)`).
**IMPORTANT - Placeholder Preservation:**