Merge pull request #170 from cave-story-randomizer/patcher

merge patcher branch into main
This commit is contained in:
duncathan salt 2023-06-19 16:51:07 -06:00 committed by GitHub
commit c9c4bf1e10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
888 changed files with 567 additions and 17493 deletions

65
.github/workflows/python.yml vendored Normal file
View file

@ -0,0 +1,65 @@
name: Python Package
on:
push:
branches:
- '*'
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
name: Wheel
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
submodules: 'recursive'
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.9"
- name: Install Python packages
run: python -m pip install --upgrade build pip
- name: build wheel
run: python -m build --wheel
- name: build sdist
run: python -m build --sdist
- name: Store the packages
uses: actions/upload-artifact@v2
with:
name: python-package-distributions
path: dist
pypi:
runs-on: 'ubuntu-latest'
needs:
- build
steps:
- name: Download all the dists
uses: actions/download-artifact@v2
with:
name: python-package-distributions
path: dist/
- name: Publish 📦 to TestPyPI
if: ${{ github.ref == 'refs/heads/patcher' }}
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.testpypi_password }}
repository_url: https://test.pypi.org/legacy/
- name: Publish 📦 to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.pypi_password }}

17
.gitignore vendored
View file

@ -1,10 +1,21 @@
data/*
notes/*
pre-edited-cs/Doukutsu\.exe\.blbkp
pre_edited_cs/Doukutsu\.exe\.blbkp
pre-edited-cs/Profile*
pre_edited_cs/Profile*
pre-edited-cs/window\.rect
pre_edited_cs/window\.rect
*Copy/
venv/
build/
dist/
cave_story_randomizer.egg-info/
*/__pycache__
caver/version.py

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

View file

@ -1,52 +1,3 @@
# Cave Story Randomizer [Open Mode]
The Cave Story Randomizer shuffles the location of every item in Cave Story, creating a new experience each time you play! The randomizer has logic in place to ensure that you can always reach every item and finish the game. Get started by heading to the [releases page](https://github.com/cave-story-randomizer/cave-story-randomizer/releases) and downloading the most recent version! If you find yourself stuck, wanting to talk about the hilarious location you found the panties in, or just plain enjoying the game please consider joining our [official Discord server](https://discord.gg/7zUdPEn) and hanging out!
## Main differences
Note that there are a few key differences from the vanilla game in order to improve the playing experience:
- All 5 teleporter locations in Arthur's House are active from the beginning of the game
- All other teleporters from the vanilla game are active and linked to one another at all times
- A teleporter between Sand Zone (near the Storehouse) and Labyrinth I has been placed and can be activated in one of two ways:
1. Defeating Toroko+
2. Using the teleporter from the Labyrinth I side
- Most cutscenes have been abridged or skipped entirely
- Jellyfish Juice can be used an infinite number of times
- You can carry as many as 5 puppies at once: Jenka will only accept them once you've collected all 5
- By the way, all 5 puppies will be located somewhere in the Sand Zone
- Certain items that are received from NPCs have been placed in chests:
- Labyrinth B (Fallen Booster)
- Labyrinth Shop
- One requiring the Machine Gun to open
- One requiring the Fireball to open
- One requiring the Spur to open
- Jail no. 1
- Storage? (Ma Pignon)
- This chest requires saving Curly in the Waterway to open
- If you don't have Curly's Air Tank after defeating the Core, the water will not rise and you may leave without dying
- Curly cannot be left behind permanently in the Core; the shutter will never close once the boss has been defeated
- The jump in the Waterway to save Curly has been made much easier
- Ironhead will always give you his item on defeat (but there's still a special surprise if you defeat him without taking damage!)
- Kazuma will only open the door between Egg no. 0 and the Outer Wall if you save him in Grasstown
- Kazuma's door can be blown down from both the outside and the inside
- Entering the Throne Room to complete the game requires doing three things:
1. Saving Sue in the Egg Corridor
2. Obtaining the Booster 2.0
3. Obtaining the Iron Bond
## Help me!
If you find yourself stuck, here are a few common pitfalls:
- Remember that the Jellyfish Juice can quench more than one fireplace
- The Graveyard can only be accessed if you obtain the Silver Locket and see Toroko get kidnapped
- The Hermit Gunsmith will wake up and give you an item if you defeat the Core and show him his gun
- The western side of the Labyrinth can be accessed without flight if you defeat Toroko+
- The Plantation can be accessed without the Teleporter Room Key if you save Kazuma and teleport in or climb the Outer Wall
- The Waterway can be accessed without the Cure-All by using the teleporter in the Labyrinth Shop
- There may be a required item in the Last Cave (Hidden) as a reward for defeating the Red Demon
If you're still stuck, join our [official Discord server](https://discord.gg/7zUdPEn) and ask for help in there!
## Credits
- Original Cave Story Randomizer by shru: https://shru.itch.io/cave-story-randomizer
- Font: https://datagoblin.itch.io/monogram
- Icon: Bubbler (@Ethan#6397)
A patcher for randomizing Cave Story. If you want to play, check out [Randovania](https://github.com/randovania/randovania)!

18
__pyinstaller/__init__.py Normal file
View file

@ -0,0 +1,18 @@
import os
# Functions
# =========
#
# .. _get_hook_dirs:
#
# get_hook_dirs
# -------------
#
# Tell PyInstaller where to find hooks provided by this distribution;
# this is referenced by the :ref:`hook registration <hook_registration>`.
# This function returns a list containing only the path to this
# directory, which is the location of these hooks.
def get_hook_dirs():
return [os.path.dirname(__file__)]

View file

@ -0,0 +1,5 @@
from PyInstaller.utils.hooks import collect_data_files
# https://pyinstaller.readthedocs.io/en/stable/hooks.html#provide-hooks-with-package
datas = collect_data_files('caver', excludes=['__pyinstaller'])

View file

@ -0,0 +1,5 @@
from PyInstaller.utils.hooks import collect_data_files
# https://pyinstaller.readthedocs.io/en/stable/hooks.html#provide-hooks-with-package
datas = collect_data_files('pre_edited_cs', excludes=['__pyinstaller'])

126
caver/patcher.py Normal file
View file

@ -0,0 +1,126 @@
from pathlib import Path
from typing import Callable, Optional
from lupa import LuaRuntime
import logging
import shutil
import textwrap
import sys
import pre_edited_cs
CSVERSION = 4
class CaverException(Exception):
pass
def get_path() -> Path:
if getattr(sys, "frozen", False):
file_dir = Path(getattr(sys, "_MEIPASS"))
else:
file_dir = Path(__file__).parent.parent
return file_dir.joinpath("caver")
def patch_files(patch_data: dict, output_dir: Path, progress_update: Callable[[str, float], None]):
progress_update("Copying base files...", -1)
ensure_base_files_exist(output_dir)
total = len(patch_data["maps"].keys()) + len(patch_data["other_tsc"].keys()) + 2
lua_file = get_path().joinpath("tsc_file.lua").read_text()
TscFile = LuaRuntime().execute(lua_file)
for i, (mapname, mapdata) in enumerate(patch_data["maps"].items()):
progress_update(f"Patching {mapname}...", i/total)
patch_map(mapname, mapdata, TscFile, output_dir)
for filename, scripts in patch_data["other_tsc"].items():
i += 1
progress_update(f"Patching {filename}.tsc...", i/total)
patch_other(filename, scripts, TscFile, output_dir)
i += 1
progress_update("Copying MyChar...", i/total)
patch_mychar(patch_data["mychar"], output_dir)
i += 1
progress_update("Copying hash...", i/total)
patch_hash(patch_data["hash"], output_dir)
def ensure_base_files_exist(output_dir: Path):
internal_copy = pre_edited_cs.get_path()
version = output_dir.joinpath("data", "Stage", "_version.txt")
keep_existing_files = version.exists() and int(version.read_text()) >= CSVERSION
def should_ignore(path: str, names: list[str]):
base = ["__init__.py", "__pycache__", "ScriptSource", "__pyinstaller"]
if keep_existing_files:
p = Path(path)
base.extend([p.joinpath(name) for name in names if p.joinpath(name).exists() and p.joinpath(name).is_file()])
return base
try:
shutil.copytree(internal_copy, output_dir, ignore=should_ignore, dirs_exist_ok=True)
except shutil.Error:
raise CaverException("Error copying base files. Ensure the directory is not read-only, and that Doukutsu.exe is closed")
output_dir.joinpath("data", "Plaintext").mkdir(exist_ok=True)
def patch_map(mapname: str, mapdata: dict[str, dict], TscFile, output_dir: Path):
mappath = output_dir.joinpath("data", "Stage", f"{mapname}.tsc")
tsc_file = TscFile.new(TscFile, mappath.read_bytes(), logging.getLogger("caver"))
for event, script in mapdata["pickups"].items():
TscFile.placeScriptAtEvent(tsc_file, script, event, mapname)
for event, song in mapdata["music"].items():
TscFile.placeSongAtCue(tsc_file, song["song_id"], event, song["original_id"], mapname)
for event, script in mapdata["entrances"].items():
needle = "<EVE...." # TODO: create a proper pattern
TscFile.placeScriptAtEvent(tsc_file, script, event, mapname, needle)
for event, hint in mapdata["hints"].items():
script = create_hint_script(hint["text"], hint.get("facepic", "0000") != "0000", hint.get("ending", "<END"))
TscFile.placeScriptAtEvent(tsc_file, script, event, mapname)
chars = TscFile.getText(tsc_file).values()
mappath.write_bytes(bytes(chars))
output_dir.joinpath("data", "Plaintext", f"{mapname}.txt").write_text(TscFile.getPlaintext(tsc_file))
def patch_other(filename: str, scripts: dict[str, dict[str, str]], TscFile, output_dir: Path):
filepath = output_dir.joinpath("data", f"{filename}.tsc")
tsc_file = TscFile.new(TscFile, filepath.read_bytes(), logging.getLogger("caver"))
for event, script in scripts.items():
TscFile.placeScriptAtEvent(tsc_file, script["script"], event, filename, script.get("needle", "<EVE...."))
chars = TscFile.getText(tsc_file).values()
filepath.write_bytes(bytes(chars))
output_dir.joinpath("data", "Plaintext", f"{filename}.txt").write_text(TscFile.getPlaintext(tsc_file))
def patch_mychar(mychar: Optional[str], output_dir: Path):
if mychar is None:
return
mychar_img = Path(mychar).read_bytes()
output_dir.joinpath("data", "MyChar.bmp").write_bytes(mychar_img)
def patch_hash(hash: list[int], output_dir: Path):
hash_strings = [f"{num:04d}" for num in hash]
hash_string = ",".join(hash_strings)
output_dir.joinpath("data", "hash.txt").write_text(hash_string)
def create_hint_script(text: str, facepic: bool, ending: str) -> str:
"""
A desperate attempt to generate valid <MSG text. Fills one text box (up to three lines). Attempts to wrap words elegantly.
"""
hard_limit = 35
msgbox_limit = 26 if facepic else hard_limit
lines = textwrap.wrap(text, width=msgbox_limit, max_lines=3)
text = ""
for i, l in enumerate(lines):
text += l
if len(l) != hard_limit and i < len(lines)-1:
text += "\r\n"
return f"<PRI<MSG<TUR{text}<NOD{ending}"

View file

@ -1,64 +1,48 @@
local C = Class:extend()
local TscFile = {}
-- local ITEM_DATA = require 'database.items'
local OPTIONAL_REPLACES = {
'Max health increased by ',
'Max life increased by ',
'<ACH0041', -- Cave Story+ only, trigger achievement.
}
function C:new(path)
logInfo('reading TSC: ' .. path)
local file = lf.newFile(path)
assert(file:open('r'))
local contents, size = file:read()
function TscFile:new(contents)
o = {}
setmetatable(o, self)
self.__index = self
self._text = self:_codec(contents, 'decode')
assert(file:close())
assert(file:release())
return o
end
function C:hasUnreplacedItems()
return #self._unreplaced >= 1
end
function C:placeItemAtLocation(item, location)
local wasChanged
self._text, wasChanged = self:_stringReplace(self._text, "<EVE....", item.script, location.event)
if not wasChanged then
local template = 'Unable to place [%s] "%s" at "%s".'
logError(template:format(location.map, item.name, location.name))
function TscFile:placeScriptAtEvent(script, event, mapname, needle)
needle = needle or "<EVE...."
local err
self._text, err = self:_stringReplace(self._text, needle, script, event)
if err ~= nil then
local template = 'Unable to place script "%s" at [%s] event "%s".\nCause: %s'
error(template:format(script, mapname, event, err))
end
end
function C:placeSongAtCue(songid, event, map, originalid)
local wasChanged
self._text, wasChanged = self:_stringReplace(self._text, "<CMU" .. originalid, "<CMU" .. songid, event, {"<CMU0015", "<CMU0000"})
if not wasChanged then
local template = "Unable to replace [%s] event #%s's music cue with %q."
logWarning(template:format(map, event, songid))
function TscFile:placeSongAtCue(songid, event, originalid, mapname)
local err
self._text, err = self:_stringReplace(self._text, "<CMU" .. originalid, "<CMU" .. songid, event, {"<CMU0015", "<CMU0000"})
if err ~= nil then
local template = "Unable to replace [%s] event #%s's music cue with %q.\nCause: %s"
error(template:format(mapname, event, songid, err))
end
end
function C:_stringReplace(text, needle, replacement, label, overrides)
function TscFile:_stringReplace(text, needle, replacement, label, overrides)
overrides = overrides or {}
local pStart, pEnd = self:_getLabelPositionRange(label)
local i, o = -1, -1
while(o <= i) do
o = nil
i = text:find(needle, pStart)
i, i2 = text:find(needle, pStart)
if i == nil then
logDebug(('Unable to replace "%s" with "%s"'):format(needle, replacement))
return text, false
local err = ('No match for "%s".'):format(needle)
return text, err
elseif i > pEnd then
-- This is totally normal and can be ignored.
logDebug(('Found "%s", but was outside of label.'):format(needle, replacement))
return text, false
local err = ('Found "%s", but was outside of label (%d, %d) at index %d.'):format(needle, pStart, pEnd, i)
return text, err
end
-- find the earliest occurence of an override
@ -79,16 +63,16 @@ function C:_stringReplace(text, needle, replacement, label, overrides)
pStart = o+1
end
local len = needle:len()
local len = i2-i+1
local j = i + len - 1
assert((i % 1 == 0) and (i > 0) and (i <= j), tostring(i))
assert((j % 1 == 0), tostring(j))
local a = text:sub(1, i - 1)
local b = text:sub(j + 1)
return a .. replacement .. b, true
return a .. replacement .. b, nil
end
function C:_getLabelPositionRange(label)
function TscFile:_getLabelPositionRange(label)
local labelStart, labelEnd
-- Recursive shit for when label is a table...
@ -128,7 +112,7 @@ function C:_getLabelPositionRange(label)
end
if labelStart == nil then
logError(("%s: Could not find label: %s"):format(self.mapName, label))
error(("%s: Could not find label: %s"):format(self.mapName, label))
labelStart = 1
end
@ -139,18 +123,15 @@ function C:_getLabelPositionRange(label)
return labelStart, labelEnd
end
function C:writePlaintextTo(path)
logInfo('writing Plaintext TSC to: ' .. path)
U.writeFile(path, self._text)
function TscFile:getPlaintext()
return self._text
end
function C:writeTo(path)
logInfo('writing TSC to: ' .. path)
local encoded = self:_codec(self._text, 'encode')
U.writeFile(path, encoded)
function TscFile:getText()
return self:_codec(self._text, 'encode')
end
function C:_codec(text, mode)
function TscFile:_codec(text, mode)
-- Create array of chars.
local chars = {}
text:gsub(".", function(c) table.insert(chars, c) end)
@ -166,20 +147,23 @@ function C:_codec(text, mode)
error('Unknown codec mode: ' .. tostring(mode))
end
logDebug(" filesize", #chars)
logDebug(" encoding char:", encodingChar)
logDebug(" encoding char position:", encodingCharPosition)
-- Encode or decode.
for pos, char in ipairs(chars) do
if pos ~= encodingCharPosition then
local byte = (char:byte() + encodingChar) % 256
chars[pos] = string.char(byte)
if mode == 'decode' then
chars[pos] = string.char(byte)
else
chars[pos] = byte
end
elseif mode == 'encode' then
chars[pos] = char:byte()
end
end
local decoded = table.concat(chars)
return decoded
if mode == 'encode' then
return chars
end
return table.concat(chars)
end
return C
return TscFile

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
-::C:FWXKFMW_::::FPKS::::FOXN-::C;FWXKFMW_::::FPKS:::;FOXN-::C<FWXKFMW_::::FPKS:::<FOXN-::C=FWXKFMW_::::FPKS:::=FOXN-::C>FWXKFMW_::::FPKS:::>FOXN-::C?-::CCFUOcFWXKFMW_::::FKXZ:;?:D:::;D::::FPKS:::>FMXZ:=:;D:;;<D:::<FaKS:;::FKXZ:;?:D::::D::::FWcN:::<FNXZ:=:;F]WMFOXN-:;::FZ\SF]K^FPVT;:<=D:;:<FS^T::<?D:;:;FW]QS~1}*vymuon8FXYNFOXN-:;:;F]Y_::<<FW]QF^_\FQS^;:<?_}on*~ro*G^ovozy|~o|*\yyw*Uoエ8FXYNFMVYFS^7::<?FQS^::::FPV5;:<=FO`O:;:<-:;:<FUOcF]Y_::;;FKXZ:;::D::::D:::<FPKY:::<F^\K::?@D::C<D:::;D:;:=
-:;?:FUOcF]K^FPVT;;>:D:;?<FS^T::<?D:;?;FPVT;:<=D:;?;FO`O:;?<-:;?;FUOcFW]QNy*ペ*x~*~y*qy*~y*K|~r|1}ry}oIFcXT::::FMVYFPV5;:B<FPV5;;>:FZ]5:::?D@::?FWcN:::<FKXZ:<?:D::;:D::::FaKS::@:FMXZ:<@:D:;?:D::::FRWMFKXZ:<@:D::;:D::::FaKS::?:FPKY:::=FaKS:;::F]WMFPVT;;>;D:;?<FPV5;;>;F^\K::?AD:<<:D::;;D::;>-:;?<FZ\SFW]QNy*ペ*x~*~y*qy*~y*K|~r|1}ry}oIFcXT::::FMVYFZ]5:::?D@::?FUOcFMXZ:=::D:;;;D:::<FRWMFaKS::@:FKXZ:;?:D:::;D::::FaKS:;::FPKY:::>F^\K:::;D::CCD:::?D:::B-:<?:FUOcFW]Q88888FXYNFOXN-:<@:-:=::-:=:;

View file

@ -1 +0,0 @@
0-FSS\S0-_pqd_fpxSSSS_idlSSSS_hqg0-FSS\T0-_pqd_fpxSSSS_idlSSST_hqg0-FSS\U0-_pqd_fpxSSSS_idlSSSU_hqg0-FSS\V0-_pqd_fpxSSSS_idlSSSV_hqg0-FSS\W0-_pqd_fpxSSSS_idlSSSW_hqg0-FSS\[0-_dpmSSSU]SSSS_dpmSSSW]SSSS_dpmSSSX]SSSS0-_dpmSSS\]SSSS_dpmSSTS]SSSS_dpmSSTU]SSSS_dpmSSTV]SSSS0-_fqsSXSS]SUTY]SSSS_pqsSXSS]SSSY]SSS\]SSSS_hqg0-0-FSTSS0-_sul_vrxSSTT_gqsSTSS_idrSSSW_wudSSU\]SS\W]SSSY]SSS\0-#SWSS0-_sul_vrxSSUU_gqsSWSS_hyhSWST0-FSWST0-_hyhSSUS0-FSWUS0-_nh|_gqsSWUS_hyhSWUT0-FSWUT0-_hyhSS[X0-FSXSS0-_pvj_wxuo鋳事C曙肢C恍労譜C倦CГ東漕飢Cz圏<7A>0-恍<>C嚠<43>酎飽C恍呂酔葛佑<E8919B>C沫<43>参_qrg0-j辰<6A><E8BEB0>C嵐C雷<43><EFBFBD>傭_zdlSSWS_|qmSSSS_for0-_olNTSSS_vrxSSUS_dhN0-_iomYUST]SXST0-_iomYUSU]SXSU0-_wudSSTV]SS\T]SSTS]SSS[0-0-FSXST0-_wudSSST]SS\W]SSS[]SSSW0-0-FSXSU0-_wudSSWS]SS\W]SSTW]SSS\0-

View file

@ -1 +0,0 @@
|y挓煥焲y<E784B2><EFBFBD>寄煙牗<E78599>案煙煙<E78599>匠|y挓煥爘y<E78898><EFBFBD>寄煙牗<E78599>案煙煚<E78599>匠|y挓煥<E68C93>y<EFBFBD><EFBFBD>寄煙牗<E78599>案煙煛<E78599>匠|y挓煥<E68C93>y<EFBFBD><EFBFBD>寄煙牗<E78599>案煙煝<E78599>匠|y挓煥<E68C93>y<EFBFBD><EFBFBD>寄煙牗<E78599>案煙煟<E78599>匠|y挓煥<E68C93>y<EFBFBD>脊煙煛<E78599>煙煫凹篃煙煙煙|y<>脊煙煥<E78599>煙煫凹篃煚々煙煙<E78599>脊煙牏<E78599>煙焲y<E784B2>娇煠煙<E785A0><E78599>ォ煙煙<E78599>娇煠煙<E785A0>煚ォ煙牊<E78599>煙煫唇硘y|y挓牊焲y<E784B2><EFBFBD>灸煙牋<E78599>娇煚煙<E7859A>熬煙煟<E78599>涟煙牊<E78599>煙ⅳ<E78599><EFBFBD>y挓<79>焲y<E784B2><EFBFBD><EFBFBD>安煙牏谱迯嗅詮柁錆潩<E98C86>骄硘y笘逶忀藻葬忊栽輳柁鋦y嗅掬萦徰哉掎詽<E68E8E>境|y刚忚掬徰嶝葜徿詮荑|y肇逶彨陡脿煚夸哌卦猬洬骄倡不莲磁礋<E7A381>爘y挓<79>爘y<E78898>糯煙煙|y挓<79>焲y<E784B2><EFBFBD><EFBFBD>安煙牏裸刿蹚辙溽徾哉銤潩<E98AA4>境|y厕溘訌阕剽忁塾忔捃休|y汜掬眼詮柁鋸戕徴剌訌自醸y捭自釓咪哌卦鈴辙釓自岙<E887AA><EFBFBD>匠|y挓<79>爘y<E78898><EFBFBD><EFBFBD>安煙牏米嵩詮苻嵩徾哉銤<E59389>境|y掇抻徾湟跐<E6B99F><EFBFBD>匠|y挓<79><E68C93>y<EFBFBD><EFBFBD><EFBFBD>安煙牏笍阕剌趶荑徲孕嶝遭|y仔逶忋汹暂徯徾刳剌謡y戕忚掬潾骄倡不撩孓徿掎詮戕徶逎潩<E9808E><EFBFBD>匠|y挓<79><E68C93>y<EFBFBD><EFBFBD><EFBFBD>安煙牏据詮苻嵩忋迯洲潩潾骄硘y笍嵩雄坭忔剽讖柁鋸孓溘觸y醒休愚輳阕詮嵩訌折捩葬鉂<E891AC><EFBFBD>匠|y挓 焲y<E784B2><EFBFBD><EFBFBD>安煙牏艰徿铔徸捩忇湄亿潾骄倡不莲陡脿煚仔銖剽徹萦栽訌掭詮拚|y荑徲孕釓咪哌卦鉂<E58DA6><EFBFBD>涣|y<>该煙煙<E78599>娇煝煚<E7859D>牏煩煙煙<E78599>娇煟煚<E7859F>安煙煙缎逶徸葬忋自彫夸哌璎潾骄倡不莲祷湡ˇ蛋矡煚⒎苘軡潩<E8BBA1>境|y绒鋿嵩徯忊捋迂葬徴徂軀y阕詮怃嵴幸詻徯嵩輺銖柁錆<E69F81><EFBFBD>涣潩潩潾骄倡不粮銖嫘廨栥徾掭謴兄迯阕秀|y卒萦嵩逾忁諒徂艳汊徾刳詜y柁鋸倚茉忋迯阕剽徹廑休訚<E4BC91>境|y厕漭阚遭鈴钾茇中鈴咴嶝庾杂|y秀忋自