build(python): simplify rpycdec vendor layout
This commit was merged in pull request #17.
This commit is contained in:
1
third_party/python/rpycdec/src/renpy/__init__.py
vendored
Normal file
1
third_party/python/rpycdec/src/renpy/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# Ren'Py AST and support modules for rpycdec
|
||||
1350
third_party/python/rpycdec/src/renpy/ast.py
vendored
Normal file
1350
third_party/python/rpycdec/src/renpy/ast.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
32
third_party/python/rpycdec/src/renpy/astsupport.py
vendored
Normal file
32
third_party/python/rpycdec/src/renpy/astsupport.py
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
class PyExpr(str):
|
||||
|
||||
expr: str
|
||||
filename: str
|
||||
linenumber: int
|
||||
py: int
|
||||
hashcode: str
|
||||
column: int
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
expr: str,
|
||||
filename: str = '',
|
||||
linenumber: int = 0,
|
||||
py: int = 3,
|
||||
hashcode: str = '',
|
||||
column: int = 0,
|
||||
):
|
||||
self = super().__new__(cls, expr)
|
||||
self.expr = expr
|
||||
self.filename = filename
|
||||
self.linenumber = linenumber
|
||||
self.py = py
|
||||
self.hashcode = hashcode
|
||||
self.column = column
|
||||
return self
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
return self.expr
|
||||
|
||||
def __str__(self):
|
||||
return self.expr
|
||||
319
third_party/python/rpycdec/src/renpy/atl.py
vendored
Normal file
319
third_party/python/rpycdec/src/renpy/atl.py
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
from . import util, ast, astsupport
|
||||
from .object import Object
|
||||
|
||||
|
||||
class Context(object):
|
||||
pass
|
||||
|
||||
|
||||
class ATLTransformBase(Object):
|
||||
pass
|
||||
|
||||
|
||||
class RawStatement(object):
|
||||
pass
|
||||
|
||||
|
||||
class Statement(Object):
|
||||
pass
|
||||
|
||||
|
||||
class RawBlock(RawStatement):
|
||||
statements = None
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#block-statement
|
||||
|
||||
atl_block_stmt ::= "block" ":"
|
||||
atl_block
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
if (
|
||||
not self.animation
|
||||
and len(self.statements) == 1
|
||||
and isinstance(self.statements[0], RawBlock)
|
||||
):
|
||||
return util.get_code(self.statements[0], **kwargs)
|
||||
|
||||
rv = []
|
||||
if self.animation:
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#animation-statement
|
||||
|
||||
atl_animation ::= "animation"
|
||||
|
||||
image eileen vhappy moving:
|
||||
animation
|
||||
"eileen vhappy"
|
||||
xalign 0.0
|
||||
linear 5.0 xalign 1.0
|
||||
repeat
|
||||
"""
|
||||
rv.append("animation")
|
||||
|
||||
statements = self.statements
|
||||
if statements:
|
||||
for statement in statements:
|
||||
if statement is None:
|
||||
rv.append("pass")
|
||||
else:
|
||||
rv.append(util.get_code(statement, **kwargs))
|
||||
else:
|
||||
rv.append("pass")
|
||||
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class Block(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawMultipurpose(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#interpolation-statement
|
||||
|
||||
atl_interp ::= ((warper simple_expression) | ("warp" simple_expression simple_expression)) (atl_interp_target+ | (":"
|
||||
atl_interp_target+ ))
|
||||
atl_interp_target ::= (atl_property+ ("knot" simple_expression)* )
|
||||
| atl_transform_expression
|
||||
| "clockwise"
|
||||
| "counterclockwise"
|
||||
| ("circles" simple_expression)
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
start = ""
|
||||
warp_fun, duration, wrap = self.warp_function, self.duration, self.warper
|
||||
if warp_fun and wrap is None:
|
||||
# warp {warp_function:expr} {duration:expr}
|
||||
start = f"warp {util.get_code(warp_fun, **kwargs)} {util.get_code(duration, **kwargs)}"
|
||||
elif not warp_fun and wrap:
|
||||
# {wrap} {duration:expr}
|
||||
start = f"{wrap} {util.get_code(duration, **kwargs)}"
|
||||
elif not warp_fun and not wrap and duration == "0":
|
||||
pass
|
||||
|
||||
rv = []
|
||||
|
||||
revolution = self.revolution
|
||||
if revolution:
|
||||
rv.append(revolution)
|
||||
|
||||
circles = self.circles
|
||||
if circles and circles != "0":
|
||||
# circles {expression}
|
||||
rv.append(f"circles {util.get_code(circles, **kwargs)}")
|
||||
|
||||
for prop, expr in self.properties:
|
||||
rv.append(f"{prop} {util.get_code(expr, **kwargs)}")
|
||||
|
||||
for prop, exprs in self.splines:
|
||||
last, exprs = exprs[-1], exprs[:-1]
|
||||
knots = " ".join(f"knot {util.get_code(expr, **kwargs)}" for expr in exprs)
|
||||
rv.append(f"{prop} {util.get_code(last, **kwargs)} {knots}")
|
||||
|
||||
for expr, with_expr in self.expressions:
|
||||
if with_expr is None:
|
||||
rv.append(str(expr))
|
||||
continue
|
||||
if isinstance(with_expr, ast.PyExpr):
|
||||
valstr = f"with {with_expr}"
|
||||
elif isinstance(with_expr, astsupport.PyExpr):
|
||||
valstr = f"with {with_expr}"
|
||||
else:
|
||||
valstr = str(with_expr)
|
||||
rv.append(f"{expr} {valstr}")
|
||||
|
||||
if not start:
|
||||
return " ".join(rv)
|
||||
if not rv:
|
||||
# if no properties, return just the warp
|
||||
return start
|
||||
if len(rv) < 3:
|
||||
# if only few properties, return them in one line
|
||||
return start + " " + " ".join(rv)
|
||||
return start + ":\n" + util.indent(" ".join(rv))
|
||||
|
||||
|
||||
class RawContainsExpr(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#inline-contains-statement
|
||||
|
||||
atl_contains ::= "contains" simple_expression
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
"""
|
||||
Returns the code for a contains expression.
|
||||
"""
|
||||
expression = util.attr(self, "expression")
|
||||
if expression:
|
||||
return f"contains {util.get_code(expression, **kwargs)}"
|
||||
return "contains"
|
||||
|
||||
|
||||
# This allows us to have multiple ATL transforms as children.
|
||||
class RawChild(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#contains-block-statement
|
||||
|
||||
atl_counts ::= "contains" ":"
|
||||
atl_block
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
children = util.attr(self, "children")
|
||||
if not children:
|
||||
return "contains:\n pass"
|
||||
rv = ["contains:"]
|
||||
rv.append(util.indent(util.get_code(children, **kwargs)))
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
# This changes the child of this statement, optionally with a transition.
|
||||
class Child(Statement):
|
||||
pass
|
||||
|
||||
|
||||
# This causes interpolation to occur.
|
||||
class Interpolation(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawRepeat(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#repeat-statement
|
||||
|
||||
atl_repeat ::= "repeat" (simple_expression)?
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
if self.repeats:
|
||||
return f"repeat {util.get_code(self.repeats, **kwargs)}"
|
||||
return "repeat"
|
||||
|
||||
|
||||
class Repeat(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawParallel(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#parallel-statement
|
||||
|
||||
atl_parallel ::= ("parallel" ":"
|
||||
atl_block)+
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = ["parallel:"]
|
||||
blocks = util.attr(self, "blocks")
|
||||
if not blocks:
|
||||
rv.append(util.indent("pass"))
|
||||
else:
|
||||
rv.append(util.indent(util.get_code(blocks, **kwargs)))
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class Parallel(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawChoice(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#choice-statement
|
||||
|
||||
atl_choice ::= ("choice" (simple_expression)? ":"
|
||||
atl_block)+
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = []
|
||||
for text, stmt in self.choices:
|
||||
rv.append(f"choice {text}:")
|
||||
rv.append(util.indent(util.get_code(stmt, **kwargs)))
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class Choice(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawTime(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/atl.html#time-statement
|
||||
|
||||
atl_time ::= "time" (simple_expression)?
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
time = util.attr(self, "time")
|
||||
if time:
|
||||
return f"time {util.get_code(time, **kwargs)}"
|
||||
return "time"
|
||||
|
||||
|
||||
class Time(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawOn(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#on-statement
|
||||
|
||||
atl_on ::= "on" name [ "," name ] * ":"
|
||||
atl_block
|
||||
|
||||
on show:
|
||||
alpha 0.0
|
||||
linear .5 alpha 1.0
|
||||
on hide:
|
||||
linear .5 alpha 0.0
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = []
|
||||
for text, stmt in self.handlers.items():
|
||||
rv.append(f"on {text}:")
|
||||
rv.append(util.indent(util.get_code(stmt, **kwargs)))
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class On(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawEvent(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#event-statement
|
||||
|
||||
atl_event ::= "event" name
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
name = util.attr(self, "name")
|
||||
if name:
|
||||
return f"event {name}"
|
||||
return "event"
|
||||
|
||||
|
||||
class Event(Statement):
|
||||
pass
|
||||
|
||||
|
||||
class RawFunction(RawStatement):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/transforms.html#function-statement
|
||||
|
||||
atl_function ::= "function" simple_expression
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
if self.expr:
|
||||
return "function " + str(self.expr)
|
||||
else:
|
||||
return "function"
|
||||
|
||||
|
||||
class Function(Statement):
|
||||
pass
|
||||
15
third_party/python/rpycdec/src/renpy/config.py
vendored
Normal file
15
third_party/python/rpycdec/src/renpy/config.py
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Optional
|
||||
|
||||
# Do we use old text substitutions?
|
||||
old_substitutions = True
|
||||
|
||||
# An alternate path to use when uneliding. (Mostly used by the launcher to enable
|
||||
# the style inspector.)
|
||||
alternate_unelide_path = None
|
||||
|
||||
# Various directories.
|
||||
gamedir = ""
|
||||
basedir = ""
|
||||
renpy_base = ""
|
||||
commondir = None # type: Optional[str]
|
||||
logdir = None # type: Optional[str] # Where log and error files go.
|
||||
71
third_party/python/rpycdec/src/renpy/display/behavior.py
vendored
Normal file
71
third_party/python/rpycdec/src/renpy/display/behavior.py
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
from . import layout, core
|
||||
from ..text import text
|
||||
from .. import object as renpyobject, python
|
||||
|
||||
|
||||
class Keymap(layout.Null):
|
||||
pass
|
||||
|
||||
|
||||
class RollForward(layout.Null):
|
||||
pass
|
||||
|
||||
|
||||
class PauseBehavior(layout.Null):
|
||||
pass
|
||||
|
||||
|
||||
class DismissBehavior(layout.Null):
|
||||
pass
|
||||
|
||||
|
||||
class SoundStopBehavior(layout.Null):
|
||||
pass
|
||||
|
||||
|
||||
class SayBehavior(layout.Null):
|
||||
pass
|
||||
|
||||
|
||||
class Button(layout.Window):
|
||||
pass
|
||||
|
||||
|
||||
class ImageButton(Button):
|
||||
pass
|
||||
|
||||
|
||||
class HoveredProxy(object):
|
||||
pass
|
||||
|
||||
|
||||
class Input(text.Text): # @UndefinedVariable
|
||||
pass
|
||||
|
||||
|
||||
class Adjustment(renpyobject.Object):
|
||||
pass
|
||||
|
||||
|
||||
class Bar(core.Displayable):
|
||||
pass
|
||||
|
||||
|
||||
class Conditional(layout.Container):
|
||||
pass
|
||||
|
||||
|
||||
class TimerState(python.RevertableObject):
|
||||
pass
|
||||
|
||||
|
||||
class Timer(layout.Null):
|
||||
pass
|
||||
|
||||
|
||||
class MouseArea(core.Displayable):
|
||||
pass
|
||||
|
||||
|
||||
class OnEvent(core.Displayable):
|
||||
pass
|
||||
33
third_party/python/rpycdec/src/renpy/display/core.py
vendored
Normal file
33
third_party/python/rpycdec/src/renpy/display/core.py
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
from .. import object as renpyobject
|
||||
|
||||
|
||||
class IgnoreEvent(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EndInteraction(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class absolute(float):
|
||||
pass
|
||||
|
||||
|
||||
class DisplayableArguments(renpyobject.Object):
|
||||
pass
|
||||
|
||||
|
||||
class Displayable(renpyobject.Object):
|
||||
pass
|
||||
|
||||
|
||||
class SceneLists(renpyobject.Object):
|
||||
pass
|
||||
|
||||
|
||||
class MouseMove(object):
|
||||
pass
|
||||
|
||||
|
||||
class Interface(object):
|
||||
pass
|
||||
10
third_party/python/rpycdec/src/renpy/display/dragdrop.py
vendored
Normal file
10
third_party/python/rpycdec/src/renpy/display/dragdrop.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
from . import core, layout
|
||||
from .. import python
|
||||
|
||||
|
||||
class Drag(core.Displayable, python.RevertableObject):
|
||||
pass
|
||||
|
||||
|
||||
class DragGroup(layout.MultiBox):
|
||||
pass
|
||||
84
third_party/python/rpycdec/src/renpy/display/layout.py
vendored
Normal file
84
third_party/python/rpycdec/src/renpy/display/layout.py
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
from ..display import core, layout
|
||||
from .. import object as renpyobject
|
||||
|
||||
|
||||
class Null(core.Displayable):
|
||||
pass
|
||||
|
||||
|
||||
class Container(core.Displayable):
|
||||
pass
|
||||
|
||||
|
||||
class Position(layout.Container):
|
||||
pass
|
||||
|
||||
|
||||
class Grid(Container):
|
||||
pass
|
||||
|
||||
|
||||
class IgnoreLayers(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MultiBox(layout.Container):
|
||||
pass
|
||||
|
||||
|
||||
class SizeGroup(renpyobject.Object):
|
||||
pass
|
||||
|
||||
|
||||
class Window(Container):
|
||||
pass
|
||||
|
||||
|
||||
class DynamicDisplayable(core.Displayable):
|
||||
pass
|
||||
|
||||
|
||||
def ConditionSwitch(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class IgnoresEvents(Container):
|
||||
pass
|
||||
|
||||
|
||||
def Crop(rect, child, **properties):
|
||||
pass
|
||||
|
||||
|
||||
LiveCrop = Crop
|
||||
|
||||
|
||||
class Side(Container):
|
||||
pass
|
||||
|
||||
|
||||
class Alpha(core.Displayable):
|
||||
pass
|
||||
|
||||
|
||||
class AdjustTimes(Container):
|
||||
pass
|
||||
|
||||
|
||||
class Tile(Container):
|
||||
pass
|
||||
|
||||
|
||||
LiveTile = Tile
|
||||
|
||||
|
||||
class Flatten(Container):
|
||||
pass
|
||||
|
||||
|
||||
class AlphaMask(Container):
|
||||
pass
|
||||
|
||||
|
||||
class NearRect(Container):
|
||||
pass
|
||||
2
third_party/python/rpycdec/src/renpy/display/transform.py
vendored
Normal file
2
third_party/python/rpycdec/src/renpy/display/transform.py
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
class Transform:
|
||||
pass
|
||||
50
third_party/python/rpycdec/src/renpy/lexer.py
vendored
Normal file
50
third_party/python/rpycdec/src/renpy/lexer.py
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
from typing import NamedTuple
|
||||
|
||||
import renpy
|
||||
|
||||
|
||||
class SubParse(object):
|
||||
"""
|
||||
This represents the information about a subparse that can be provided to
|
||||
a creator-defined statement.
|
||||
"""
|
||||
|
||||
def __init__(self, block):
|
||||
self.block = block
|
||||
|
||||
def __repr__(self):
|
||||
if not self.block:
|
||||
return "<SubParse empty>"
|
||||
else:
|
||||
return "<SubParse {}:{}>".format(
|
||||
self.block[0].filename, self.block[0].linenumber
|
||||
)
|
||||
|
||||
|
||||
def unelide_filename(fn: str) -> str:
|
||||
fn = os.path.normpath(fn)
|
||||
|
||||
if renpy.config.alternate_unelide_path is not None:
|
||||
fn0 = os.path.join(renpy.config.alternate_unelide_path, fn)
|
||||
if os.path.exists(fn0):
|
||||
return fn0
|
||||
|
||||
fn1 = os.path.join(renpy.config.basedir, fn)
|
||||
if os.path.exists(fn1):
|
||||
return fn1
|
||||
|
||||
fn2 = os.path.join(renpy.config.renpy_base, fn)
|
||||
if os.path.exists(fn2):
|
||||
return fn2
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
class GroupedLine(NamedTuple):
|
||||
# The filename the line is from.
|
||||
filename: str
|
||||
number: int
|
||||
indent: int
|
||||
text: str
|
||||
block: list["GroupedLine"]
|
||||
33
third_party/python/rpycdec/src/renpy/object.py
vendored
Normal file
33
third_party/python/rpycdec/src/renpy/object.py
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
class Object(object):
|
||||
__version__ = 0
|
||||
|
||||
nosave = []
|
||||
|
||||
def __getstate__(self):
|
||||
rv = vars(self).copy()
|
||||
|
||||
for f in self.nosave:
|
||||
if f in rv:
|
||||
del rv[f]
|
||||
|
||||
rv["__version__"] = self.__version__
|
||||
|
||||
return rv
|
||||
|
||||
# None, to prevent this from being called when unnecessary.
|
||||
after_setstate = None
|
||||
|
||||
def __setstate__(self, new_dict):
|
||||
version = new_dict.pop("__version__", 0)
|
||||
|
||||
self.__dict__.update(new_dict)
|
||||
|
||||
if version != self.__version__:
|
||||
self.after_upgrade(version) # E1101
|
||||
|
||||
if self.after_setstate:
|
||||
self.after_setstate() # E1102
|
||||
|
||||
|
||||
class Sentinel(object):
|
||||
pass
|
||||
63
third_party/python/rpycdec/src/renpy/parameter.py
vendored
Normal file
63
third_party/python/rpycdec/src/renpy/parameter.py
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
from .. import util
|
||||
|
||||
|
||||
class Signature:
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = []
|
||||
parameters = util.attr(self, "parameters")
|
||||
if isinstance(parameters, dict):
|
||||
for name, value in parameters.items():
|
||||
rv.append(util.get_code(value, **kwargs))
|
||||
else:
|
||||
for value in parameters:
|
||||
rv.append(str(value))
|
||||
if not rv:
|
||||
return ""
|
||||
return f"({', '.join(rv)})"
|
||||
|
||||
|
||||
class Parameter:
|
||||
|
||||
(
|
||||
POSITIONAL_ONLY,
|
||||
POSITIONAL_OR_KEYWORD,
|
||||
VAR_POSITIONAL,
|
||||
KEYWORD_ONLY,
|
||||
VAR_KEYWORD,
|
||||
) = range(5)
|
||||
|
||||
def _variable_or_default(self, name, default):
|
||||
return f"{name}={default}" if default is not None else f"{name}"
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
name = util.attr(self, "name")
|
||||
kind = util.attr(self, "kind")
|
||||
default = util.attr(self, "default")
|
||||
if kind == self.POSITIONAL_ONLY:
|
||||
return self._variable_or_default(name, default)
|
||||
elif kind == self.POSITIONAL_OR_KEYWORD:
|
||||
return self._variable_or_default(name, default)
|
||||
elif kind == self.VAR_POSITIONAL:
|
||||
return f"*{name}"
|
||||
elif kind == self.KEYWORD_ONLY:
|
||||
return self._variable_or_default(name, default)
|
||||
elif kind == self.VAR_KEYWORD:
|
||||
return f"**{name}"
|
||||
else:
|
||||
raise ValueError(f"Unknown parameter kind: {kind}")
|
||||
|
||||
|
||||
class ArgumentInfo:
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = []
|
||||
for i, (keyword, expression) in enumerate(self.arguments):
|
||||
if i in self.starred_indexes:
|
||||
rv.append("*" + expression)
|
||||
elif i in self.doublestarred_indexes:
|
||||
rv.append("**" + expression)
|
||||
elif keyword is not None:
|
||||
rv.append("{}={}".format(keyword, expression))
|
||||
else:
|
||||
rv.append(str(expression))
|
||||
return "(" + ", ".join(rv) + ")"
|
||||
25
third_party/python/rpycdec/src/renpy/pyanalysis.py
vendored
Normal file
25
third_party/python/rpycdec/src/renpy/pyanalysis.py
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import ast
|
||||
|
||||
# Three levels of constness.
|
||||
GLOBAL_CONST = 2 # Expressions that are const everywhere.
|
||||
LOCAL_CONST = 1 # Expressions that are const with regard to a screen + parameters.
|
||||
NOT_CONST = 0 # Expressions that are not const.
|
||||
|
||||
|
||||
class Control(object):
|
||||
pass
|
||||
|
||||
|
||||
class Analysis(object):
|
||||
pass
|
||||
|
||||
|
||||
class PyAnalysis(ast.NodeVisitor):
|
||||
pass
|
||||
|
||||
|
||||
class CompilerCache(object):
|
||||
pass
|
||||
|
||||
|
||||
ccache = CompilerCache()
|
||||
82
third_party/python/rpycdec/src/renpy/python.py
vendored
Normal file
82
third_party/python/rpycdec/src/renpy/python.py
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
import ast
|
||||
import random
|
||||
from .object import Object
|
||||
|
||||
|
||||
class StoreDeleted(object):
|
||||
pass
|
||||
|
||||
|
||||
class StoreModule(object):
|
||||
pass
|
||||
|
||||
|
||||
class StoreDict(dict):
|
||||
pass
|
||||
|
||||
|
||||
class StoreBackup:
|
||||
pass
|
||||
|
||||
|
||||
class NoRollback(object):
|
||||
pass
|
||||
|
||||
|
||||
class WrapNode(ast.NodeTransformer):
|
||||
pass
|
||||
|
||||
|
||||
class CompressedList(object):
|
||||
pass
|
||||
|
||||
|
||||
class RevertableList(list):
|
||||
pass
|
||||
|
||||
|
||||
class RevertableDict(dict):
|
||||
pass
|
||||
|
||||
|
||||
class RevertableSet(set):
|
||||
def __setstate__(self, state):
|
||||
if isinstance(state, tuple):
|
||||
self.update(state[0].keys())
|
||||
else:
|
||||
self.update(state)
|
||||
|
||||
__reduce__ = object.__reduce__
|
||||
__reduce_ex__ = object.__reduce_ex__
|
||||
|
||||
|
||||
class RevertableObject(object):
|
||||
pass
|
||||
|
||||
|
||||
class RollbackRandom(random.Random):
|
||||
pass
|
||||
|
||||
|
||||
class DetRandom(random.Random):
|
||||
pass
|
||||
|
||||
|
||||
class Rollback(Object):
|
||||
pass
|
||||
|
||||
|
||||
class RollbackLog(Object):
|
||||
pass
|
||||
|
||||
|
||||
class StoreProxy(object):
|
||||
pass
|
||||
|
||||
|
||||
def py_compile(source, mode, filename="<none>", lineno=1, ast_node=False, cache=True):
|
||||
pass
|
||||
|
||||
|
||||
def py_eval_bytecode(bytecode, globals=None, locals=None): # @ReservedAssignment
|
||||
pass
|
||||
83
third_party/python/rpycdec/src/renpy/revertable.py
vendored
Normal file
83
third_party/python/rpycdec/src/renpy/revertable.py
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
import ast
|
||||
import random
|
||||
from .object import Object
|
||||
|
||||
|
||||
class StoreDeleted(object):
|
||||
pass
|
||||
|
||||
|
||||
class StoreModule(object):
|
||||
pass
|
||||
|
||||
|
||||
class StoreDict(dict):
|
||||
pass
|
||||
|
||||
|
||||
class StoreBackup:
|
||||
pass
|
||||
|
||||
|
||||
class NoRollback(object):
|
||||
pass
|
||||
|
||||
|
||||
class WrapNode(ast.NodeTransformer):
|
||||
pass
|
||||
|
||||
|
||||
class CompressedList(object):
|
||||
pass
|
||||
|
||||
|
||||
class RevertableList(list):
|
||||
pass
|
||||
|
||||
|
||||
class RevertableDict(dict):
|
||||
pass
|
||||
|
||||
|
||||
class RevertableSet(set):
|
||||
def __setstate__(self, state):
|
||||
if isinstance(state, tuple):
|
||||
self.update(state[0].keys())
|
||||
else:
|
||||
self.update(state)
|
||||
|
||||
# Required to ensure that getstate and setstate are called.
|
||||
__reduce__ = object.__reduce__
|
||||
__reduce_ex__ = object.__reduce_ex__
|
||||
|
||||
|
||||
class RevertableObject(object):
|
||||
pass
|
||||
|
||||
|
||||
class RollbackRandom(random.Random):
|
||||
pass
|
||||
|
||||
|
||||
class DetRandom(random.Random):
|
||||
pass
|
||||
|
||||
|
||||
class Rollback(Object):
|
||||
pass
|
||||
|
||||
|
||||
class RollbackLog(Object):
|
||||
pass
|
||||
|
||||
|
||||
class StoreProxy(object):
|
||||
pass
|
||||
|
||||
|
||||
def py_compile(source, mode, filename="<none>", lineno=1, ast_node=False, cache=True):
|
||||
pass
|
||||
|
||||
|
||||
def py_eval_bytecode(bytecode, globals=None, locals=None): # @ReservedAssignment
|
||||
pass
|
||||
12
third_party/python/rpycdec/src/renpy/script.py
vendored
Normal file
12
third_party/python/rpycdec/src/renpy/script.py
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
def collapse_stmts(stmts):
|
||||
"""
|
||||
Returns a flat list containing every statement in the tree
|
||||
stmts.
|
||||
"""
|
||||
|
||||
rv = []
|
||||
|
||||
for i in stmts:
|
||||
i.get_children(rv.append)
|
||||
|
||||
return rv
|
||||
323
third_party/python/rpycdec/src/renpy/sl2/slast.py
vendored
Normal file
323
third_party/python/rpycdec/src/renpy/sl2/slast.py
vendored
Normal file
@@ -0,0 +1,323 @@
|
||||
import logging
|
||||
from .. import ast, astsupport, ui, util
|
||||
|
||||
|
||||
class SLContext(ui.Addable):
|
||||
pass
|
||||
|
||||
|
||||
class SLNode(object):
|
||||
pass
|
||||
|
||||
|
||||
class SLBlock(SLNode):
|
||||
"""
|
||||
foo bar
|
||||
baz 1
|
||||
|
||||
expression
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = []
|
||||
if self.keyword:
|
||||
rv.append(util.get_code_properties(self.keyword, newline=True))
|
||||
if self.children:
|
||||
rv.append(util.get_code(self.children, **kwargs))
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class SLCache(object):
|
||||
pass
|
||||
|
||||
|
||||
class SLDisplayable(SLBlock):
|
||||
"""
|
||||
`displayable`
|
||||
A function that, when called with the positional and keyword
|
||||
arguments, causes the displayable to be displayed.
|
||||
|
||||
`scope`
|
||||
If true, the scope is supplied as an argument to the displayable.
|
||||
|
||||
`child_or_fixed`
|
||||
If true and the number of children of this displayable is not one,
|
||||
the children are added to a Fixed, and the Fixed is added to the
|
||||
displayable.
|
||||
|
||||
`style`
|
||||
The base name of the main style.
|
||||
|
||||
`pass_context`
|
||||
If given, the context is passed in as the first positonal argument
|
||||
of the displayable.
|
||||
|
||||
`imagemap`
|
||||
True if this is an imagemap, and should be handled as one.
|
||||
|
||||
`hotspot`
|
||||
True if this is a hotspot that depends on the imagemap it was
|
||||
first displayed with.
|
||||
|
||||
`replaces`
|
||||
True if the object this displayable replaces should be
|
||||
passed to it.
|
||||
|
||||
`default_keywords`
|
||||
The default keyword arguments to supply to the displayable.
|
||||
|
||||
`variable`
|
||||
A variable that the main displayable is assigned to.
|
||||
|
||||
https://www.renpy.org/doc/html/screens.html#add-statement
|
||||
https://www.renpy.org/doc/html/screens.html#use
|
||||
https://www.renpy.org/doc/html/screens.html#has
|
||||
https://www.renpy.org/doc/html/screens.html#imagebutton
|
||||
"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
# higher version use style instead of name
|
||||
name = getattr(self, "name", None)
|
||||
displayable = getattr(self, "displayable", None)
|
||||
style = getattr(self, "style", None)
|
||||
start = ""
|
||||
if name:
|
||||
start = name
|
||||
elif displayable:
|
||||
# displayable function named sl2xxx like sl2vbar , sl2viewport
|
||||
displable_name = displayable.__name__.lower()
|
||||
if displable_name.startswith("sl2"):
|
||||
start = displable_name.replace("sl2", "")
|
||||
elif displable_name == "onevent":
|
||||
start = "on"
|
||||
else:
|
||||
start = displable_name.replace("_", "")
|
||||
if style:
|
||||
match start:
|
||||
case "multibox":
|
||||
start = style
|
||||
case "window":
|
||||
start = style
|
||||
if not start:
|
||||
logging.warning(
|
||||
"warning: displayable not found, use textbutton instead, you may need fix it manually"
|
||||
)
|
||||
start = "textbutton"
|
||||
return start
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
# higher version use style instead of name
|
||||
start = self.get_name()
|
||||
if not start:
|
||||
raise Exception("displayable not found")
|
||||
|
||||
variable = util.attr(self, "variable")
|
||||
if variable:
|
||||
start += f" as {variable}"
|
||||
|
||||
tag = util.attr(self, "tag")
|
||||
if tag:
|
||||
start += f" tag {tag}"
|
||||
|
||||
positional = util.attr(self, "positional")
|
||||
if positional:
|
||||
if isinstance(positional, list):
|
||||
start += " " + " ".join(util.get_code(p, **kwargs) for p in positional)
|
||||
else:
|
||||
start += " " + util.get_code(positional, **kwargs)
|
||||
# keywords
|
||||
keyword = util.attr(self, "keyword")
|
||||
children = util.attr(self, "children")
|
||||
if not children:
|
||||
if keyword:
|
||||
start += f" {util.get_code_properties(keyword)}"
|
||||
return start
|
||||
# children
|
||||
rv = [start + ":"]
|
||||
if keyword:
|
||||
rv.append(util.indent(util.get_code_properties(keyword, newline=True)))
|
||||
rv.append(util.indent(util.get_code(children, **kwargs)))
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class SLIf(SLNode):
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = []
|
||||
for index, (cond, body) in enumerate(self.entries):
|
||||
body_code = util.get_code(body, **kwargs)
|
||||
if not body_code:
|
||||
body_code = "pass"
|
||||
body_code = util.indent(body_code)
|
||||
if index == 0:
|
||||
rv.append(f"if {cond}:\n{body_code}")
|
||||
continue
|
||||
if cond and cond != "True":
|
||||
rv.append(f"elif {cond}:\n{body_code}")
|
||||
else:
|
||||
rv.append(f"else:\n{body_code}")
|
||||
break
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class SLShowIf(SLNode):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/screens.html#showif-statement
|
||||
|
||||
showif n == 3:
|
||||
text "Three" size 100 at cd_transform
|
||||
elif n == 2:
|
||||
text "Two" size 100 at cd_transform
|
||||
elif n == 1:
|
||||
text "One" size 100 at cd_transform
|
||||
else:
|
||||
text "Liftoff!" size 100 at cd_transform
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = []
|
||||
for index, (cond, body) in enumerate(self.entries):
|
||||
body_code = util.get_code(body, **kwargs)
|
||||
if not body_code:
|
||||
body_code = "pass"
|
||||
body_code = util.indent(body_code)
|
||||
if index == 0:
|
||||
rv.append(f"showif {cond}:\n{body_code}")
|
||||
continue
|
||||
if cond and cond != "True":
|
||||
rv.append(f"elif {cond}:\n{body_code}")
|
||||
else:
|
||||
rv.append(f"else:\n{body_code}")
|
||||
break
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class SLFor(SLBlock):
|
||||
pass
|
||||
|
||||
"""
|
||||
screen five_buttons():
|
||||
vbox:
|
||||
for i, numeral index numeral in enumerate(numerals):
|
||||
textbutton numeral action Return(i + 1)
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
rv = [f"for {self.variable} in {self.expression}:"]
|
||||
children = util.attr(self, "children")
|
||||
if not children:
|
||||
rv.append(util.indent("pass"))
|
||||
else:
|
||||
rv.append(util.indent(util.get_code(children, **kwargs)))
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class SLPython(SLNode):
|
||||
def get_code(self, **kwargs) -> str:
|
||||
inner_code = util.get_code(self.code, **kwargs)
|
||||
if not inner_code:
|
||||
return ""
|
||||
if len(inner_code.splitlines()) == 1:
|
||||
return f"$ {inner_code}"
|
||||
return f"python:\n{util.indent(inner_code)}"
|
||||
|
||||
|
||||
class SLPass(SLNode):
|
||||
pass
|
||||
|
||||
|
||||
class SLDefault(SLNode):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/python.html#default-statement
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
return f"default {self.variable} = {self.expression}"
|
||||
|
||||
|
||||
class SLUse(SLNode):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/screens.html#use
|
||||
|
||||
use [expression EXPR pass|WORD] ARGUMENTS [id EXPR][:]
|
||||
BLOCK
|
||||
"""
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
start = "use"
|
||||
target = util.attr(self, "target")
|
||||
if target:
|
||||
if isinstance(target, ast.PyExpr):
|
||||
start += f" expression {util.get_code(target, **kwargs)} pass "
|
||||
elif isinstance(target, astsupport.PyExpr):
|
||||
start += f" expression {util.get_code(target, **kwargs)} pass "
|
||||
else:
|
||||
start += f" {target}"
|
||||
if self.args:
|
||||
start += f"{util.get_code(self.args, **kwargs)}"
|
||||
if self.block:
|
||||
return util.label_code(start, self.block, **kwargs)
|
||||
if self.ast:
|
||||
return util.label_code(start, self.ast, **kwargs)
|
||||
return start
|
||||
|
||||
|
||||
# https://www.renpy.org/doc/html/screens.html#use-and-transclude
|
||||
"""
|
||||
screen movable_frame(pos):
|
||||
drag:
|
||||
pos pos
|
||||
|
||||
frame:
|
||||
background Frame("movable_frame.png", 10, 10)
|
||||
top_padding 20
|
||||
|
||||
transclude
|
||||
"""
|
||||
|
||||
|
||||
class SLTransclude(SLNode):
|
||||
def get_code(self, **kwargs) -> str:
|
||||
return "transclude"
|
||||
|
||||
|
||||
class SLScreen(SLBlock):
|
||||
"""
|
||||
https://www.renpy.org/doc/html/screens.html#screen-statement
|
||||
"""
|
||||
|
||||
parameters: ast.ParameterInfo
|
||||
keyword: list
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
start = "screen"
|
||||
if self.name:
|
||||
start += f" {self.name}"
|
||||
if self.parameters:
|
||||
start += f"{util.get_code(self.parameters, **kwargs)}"
|
||||
|
||||
properties = self.keyword.copy()
|
||||
tag = util.attr(self, "tag")
|
||||
if tag:
|
||||
properties.append(("tag", tag))
|
||||
|
||||
rv = [start + ":"]
|
||||
# keyword
|
||||
if properties:
|
||||
rv.append(util.indent(util.get_code_properties(properties, newline=True)))
|
||||
rv.append("") # add a newline after keyword
|
||||
# children
|
||||
children = util.attr(self, "children")
|
||||
if children:
|
||||
rv.append(util.indent(util.get_code(children, **kwargs)))
|
||||
if len(rv) == 1:
|
||||
rv.append("pass")
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
class ScreenCache(object):
|
||||
pass
|
||||
|
||||
|
||||
class SLContinue(SLNode):
|
||||
def get_code(self, **kwargs) -> str:
|
||||
return "continue"
|
||||
25
third_party/python/rpycdec/src/renpy/sl2/sldisplayables.py
vendored
Normal file
25
third_party/python/rpycdec/src/renpy/sl2/sldisplayables.py
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
from ..display import layout
|
||||
|
||||
|
||||
class ShowIf(layout.Container):
|
||||
pass
|
||||
|
||||
|
||||
def sl2vbar(context=None, **properties):
|
||||
pass
|
||||
|
||||
|
||||
def sl2viewport(context=None, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def sl2vpgrid(context=None, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def sl2add(d, replaces=None, scope=None, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def sl2bar(context=None, **properties):
|
||||
pass
|
||||
0
third_party/python/rpycdec/src/renpy/test/__init__.py
vendored
Normal file
0
third_party/python/rpycdec/src/renpy/test/__init__.py
vendored
Normal file
2
third_party/python/rpycdec/src/renpy/text/extras.py
vendored
Normal file
2
third_party/python/rpycdec/src/renpy/text/extras.py
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
class ParameterizedText(object):
|
||||
pass
|
||||
26
third_party/python/rpycdec/src/renpy/text/font.py
vendored
Normal file
26
third_party/python/rpycdec/src/renpy/text/font.py
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
class ImageFont(object):
|
||||
pass
|
||||
|
||||
|
||||
class FontGroup(object):
|
||||
pass
|
||||
|
||||
|
||||
class ScaledImageFont(ImageFont):
|
||||
pass
|
||||
|
||||
|
||||
class BMFont(ImageFont):
|
||||
pass
|
||||
|
||||
|
||||
class MudgeFont(ImageFont):
|
||||
pass
|
||||
|
||||
|
||||
class SFont(ImageFont):
|
||||
pass
|
||||
|
||||
|
||||
class ImageFont(object):
|
||||
pass
|
||||
33
third_party/python/rpycdec/src/renpy/text/text.py
vendored
Normal file
33
third_party/python/rpycdec/src/renpy/text/text.py
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
from ..display import core
|
||||
|
||||
|
||||
class Blit(object):
|
||||
pass
|
||||
|
||||
|
||||
class DrawInfo(object):
|
||||
pass
|
||||
|
||||
|
||||
class TextSegment(object):
|
||||
pass
|
||||
|
||||
|
||||
class SpaceSegment(object):
|
||||
pass
|
||||
|
||||
|
||||
class DisplayableSegment(object):
|
||||
pass
|
||||
|
||||
|
||||
class FlagSegment(object):
|
||||
pass
|
||||
|
||||
|
||||
class Layout(object):
|
||||
pass
|
||||
|
||||
|
||||
class Text(core.Displayable):
|
||||
pass
|
||||
28
third_party/python/rpycdec/src/renpy/translation/__init__.py
vendored
Normal file
28
third_party/python/rpycdec/src/renpy/translation/__init__.py
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
import re
|
||||
|
||||
|
||||
def encode_say_string(s):
|
||||
"""
|
||||
Encodes a string in the format used by Ren'Py say statements.
|
||||
"""
|
||||
|
||||
s = s.replace("\\", "\\\\")
|
||||
s = s.replace("\n", "\\n")
|
||||
s = s.replace('"', '\\"')
|
||||
s = re.sub(r"(?<= ) ", "\\ ", s)
|
||||
|
||||
return '"' + s + '"'
|
||||
|
||||
|
||||
def quote_unicode(s):
|
||||
s = s.replace("\\", "\\\\")
|
||||
s = s.replace('"', '\\"')
|
||||
s = s.replace("\a", "\\a")
|
||||
s = s.replace("\b", "\\b")
|
||||
s = s.replace("\f", "\\f")
|
||||
s = s.replace("\n", "\\n")
|
||||
s = s.replace("\r", "\\r")
|
||||
s = s.replace("\t", "\\t")
|
||||
s = s.replace("\v", "\\v")
|
||||
|
||||
return s
|
||||
27
third_party/python/rpycdec/src/renpy/translation/generation.py
vendored
Normal file
27
third_party/python/rpycdec/src/renpy/translation/generation.py
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
import renpy
|
||||
|
||||
|
||||
def shorten_filename(filename):
|
||||
"""
|
||||
Shortens a file name. Returns the shortened filename, and a flag that says
|
||||
if the filename is in the common directory.
|
||||
"""
|
||||
|
||||
commondir = os.path.normpath(renpy.config.commondir) # type: ignore
|
||||
gamedir = os.path.normpath(renpy.config.gamedir)
|
||||
|
||||
if filename.startswith(commondir):
|
||||
fn = os.path.relpath(filename, commondir)
|
||||
common = True
|
||||
|
||||
elif filename.startswith(gamedir):
|
||||
fn = os.path.relpath(filename, gamedir)
|
||||
common = False
|
||||
|
||||
else:
|
||||
fn = os.path.basename(filename)
|
||||
common = False
|
||||
|
||||
return fn, common
|
||||
9
third_party/python/rpycdec/src/renpy/translation/scanstrings.py
vendored
Normal file
9
third_party/python/rpycdec/src/renpy/translation/scanstrings.py
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
STRING_RE = r"""(?x)
|
||||
\b(?:_|__|___|_p)
|
||||
\s*(\((?:[\s\\\n]*[uU]?(?:
|
||||
\"\"\"(?:\\.|\\\n|\"{1,2}|[^\\"])*?\"\"\"
|
||||
|'''(?:\\.|\\\n|\'{1,2}|[^\\'])*?'''
|
||||
|"(?:\\.|\\\n|[^\\"])*"
|
||||
|'(?:\\.|\\\n|[^\\'])*'
|
||||
))+\s*\))
|
||||
"""
|
||||
96
third_party/python/rpycdec/src/renpy/ui.py
vendored
Normal file
96
third_party/python/rpycdec/src/renpy/ui.py
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
from .object import Object
|
||||
|
||||
|
||||
class Action(Object):
|
||||
pass
|
||||
|
||||
|
||||
class BarValue(Object):
|
||||
pass
|
||||
|
||||
|
||||
class Addable(object):
|
||||
pass
|
||||
|
||||
|
||||
class Layer(Addable):
|
||||
pass
|
||||
|
||||
|
||||
class Many(Addable):
|
||||
pass
|
||||
|
||||
|
||||
class One(Addable):
|
||||
pass
|
||||
|
||||
|
||||
class Detached(Addable):
|
||||
pass
|
||||
|
||||
|
||||
class ChildOrFixed(Addable):
|
||||
pass
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
pass
|
||||
|
||||
|
||||
def _hotspot(spot, style="hotspot", **properties):
|
||||
pass
|
||||
|
||||
|
||||
def _imagebutton(
|
||||
idle_image=None,
|
||||
hover_image=None,
|
||||
insensitive_image=None,
|
||||
activate_image=None,
|
||||
selected_idle_image=None,
|
||||
selected_hover_image=None,
|
||||
selected_insensitive_image=None,
|
||||
selected_activate_image=None,
|
||||
idle=None,
|
||||
hover=None,
|
||||
insensitive=None,
|
||||
selected_idle=None,
|
||||
selected_hover=None,
|
||||
selected_insensitive=None,
|
||||
image_style=None,
|
||||
auto=None,
|
||||
**properties,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
def _imagemap(
|
||||
ground,
|
||||
selected,
|
||||
hotspots,
|
||||
unselected=None,
|
||||
overlays=False,
|
||||
style="imagemap",
|
||||
mouse="imagemap",
|
||||
with_none=None,
|
||||
**properties,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
def _textbutton(
|
||||
label,
|
||||
clicked=None,
|
||||
style=None,
|
||||
text_style=None,
|
||||
substitute=True,
|
||||
scope=None,
|
||||
**kwargs,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
def _key(key, action=None, activate_sound=None):
|
||||
pass
|
||||
|
||||
|
||||
def _label(label, style=None, text_style=None, substitute=True, scope=None, **kwargs):
|
||||
pass
|
||||
250
third_party/python/rpycdec/src/renpy/util.py
vendored
Normal file
250
third_party/python/rpycdec/src/renpy/util.py
vendored
Normal file
@@ -0,0 +1,250 @@
|
||||
from . import ast
|
||||
from collections import deque
|
||||
|
||||
IDENT_CHAR = " "
|
||||
|
||||
|
||||
def indent(code: str, level: int = 1) -> str:
|
||||
"""
|
||||
Indent each line of code by a specified level.
|
||||
|
||||
Args:
|
||||
code: The string to indent
|
||||
level: Number of indentation levels to apply (default: 1)
|
||||
|
||||
Returns:
|
||||
The indented string
|
||||
"""
|
||||
if not code:
|
||||
return ""
|
||||
indent_str = IDENT_CHAR * level
|
||||
# add indentation to each non-empty line
|
||||
# keep empty lines as they are
|
||||
rv = [indent_str + line if line.strip() else line for line in code.split("\n")]
|
||||
return "\n".join(rv)
|
||||
|
||||
|
||||
def get_code_properties(props: tuple | dict, newline: bool = False, **kwargs) -> str:
|
||||
"""
|
||||
:param keyword: tuple | dict
|
||||
:param newline: bool
|
||||
:return: str
|
||||
|
||||
>>> get_code_properties((("a", 1), ("b", 2)))
|
||||
a 1 b 2
|
||||
>>> get_code_properties((("a", 1), (None, b)), newline=True)
|
||||
a 1
|
||||
b
|
||||
>>> get_code_properties({"a": 1, "b": None})
|
||||
a 1
|
||||
b None
|
||||
"""
|
||||
list = []
|
||||
if isinstance(props, dict):
|
||||
props = props.items()
|
||||
for prop in props:
|
||||
if isinstance(prop, tuple) and len(prop) == 2:
|
||||
key, value = prop[0], prop[1]
|
||||
valstr = get_code(value, **kwargs)
|
||||
if not valstr:
|
||||
list.append(f"{key}")
|
||||
else:
|
||||
list.append(f"{key} {valstr}")
|
||||
continue
|
||||
else:
|
||||
prop_str = " ".join([str(x) for x in prop])
|
||||
if not prop_str:
|
||||
continue
|
||||
list.append(prop_str)
|
||||
return ("\n" if newline else " ").join(list)
|
||||
|
||||
|
||||
def get_code(node, **kwargs) -> str:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
node : ast.Node
|
||||
kwargs : dict
|
||||
indent : int
|
||||
space indent level
|
||||
modifier : Callable[[ast.Node], ast.Node]
|
||||
modify node before get code
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
generated code
|
||||
|
||||
Raises
|
||||
------
|
||||
NotImplementedError
|
||||
if node type is not implemented or some attributes unable to handle.
|
||||
|
||||
"""
|
||||
|
||||
if isinstance(node, str):
|
||||
return node
|
||||
if isinstance(node, list):
|
||||
rv = []
|
||||
items = deque(node)
|
||||
while items:
|
||||
item = items.popleft()
|
||||
|
||||
# [Say|UserStatement?, Menu]
|
||||
if isinstance(item, ast.Say) or isinstance(item, ast.UserStatement):
|
||||
next = items[0] if items else None
|
||||
if isinstance(next, ast.Menu) and attr(next, "statement_start") == item:
|
||||
menu = items.popleft()
|
||||
call_kwargs = kwargs.copy()
|
||||
call_kwargs["menu_say"] = item
|
||||
rv.append(get_code(menu, **call_kwargs))
|
||||
continue
|
||||
|
||||
# [Label, Say|UserStatement?, Menu]
|
||||
if isinstance(item, ast.Label):
|
||||
next = items[0] if items else None
|
||||
# [Label, Say|UserStatement, Menu]
|
||||
if isinstance(next, ast.Say) or isinstance(next, ast.UserStatement):
|
||||
next_next = items[1] if len(items) > 1 else None
|
||||
if (
|
||||
isinstance(next_next, ast.Menu)
|
||||
and attr(next_next, "statement_start") == item
|
||||
):
|
||||
say = items.popleft()
|
||||
menu = items.popleft()
|
||||
call_kwargs = kwargs.copy()
|
||||
call_kwargs["menu_label"] = item
|
||||
call_kwargs["menu_say"] = say
|
||||
rv.append(get_code(menu, **call_kwargs))
|
||||
continue
|
||||
# [Label, Menu]
|
||||
if isinstance(next, ast.Menu) and attr(next, "statement_start") == item:
|
||||
menu = items.popleft()
|
||||
call_kwargs = kwargs.copy()
|
||||
call_kwargs["menu_label"] = item
|
||||
rv.append(get_code(menu, **call_kwargs))
|
||||
continue
|
||||
|
||||
if isinstance(item, ast.With):
|
||||
# some node quoted by two ast.With if has with suffix expr, the sdk snippet:
|
||||
#
|
||||
# renpy.parser.parse_with(node):
|
||||
# if not "with":
|
||||
# return node
|
||||
# expr = simple_expression
|
||||
# return [ast.With(loc, "None", paired=expr), node, ast.With(loc, expr)]
|
||||
#
|
||||
# - ast.Scene
|
||||
# - ast.Show
|
||||
# - ast.Hide
|
||||
expr, paired = attr(item, "expr"), attr(item, "paired")
|
||||
if (not expr or expr == "None") and paired:
|
||||
next_next = items[1] if len(items) > 1 else None
|
||||
if (
|
||||
isinstance(next_next, ast.With)
|
||||
and attr(next_next, "expr") == paired
|
||||
):
|
||||
node = items.popleft()
|
||||
close_with = items.popleft()
|
||||
call_kwargs = kwargs.copy()
|
||||
call_kwargs.update({"with_expr": attr(close_with, "expr")})
|
||||
rv.append(get_code(node, **call_kwargs))
|
||||
continue
|
||||
|
||||
if isinstance(item, ast.Call):
|
||||
# call = [Call, Label?, Pass]
|
||||
from_label = None
|
||||
# try to pop next Label|Pass
|
||||
next = items.popleft() if items else None
|
||||
if isinstance(next, ast.Label):
|
||||
from_label = next
|
||||
# pop Pass
|
||||
items.popleft() if items else None
|
||||
|
||||
if from_label is not None:
|
||||
call_kwargs = kwargs.copy()
|
||||
call_kwargs["from_label"] = from_label
|
||||
rv.append(get_code(item, **call_kwargs))
|
||||
continue
|
||||
|
||||
if isinstance(item, ast.Return):
|
||||
if (
|
||||
# it cant tell out an anto appended Return or an empty one
|
||||
# currently we must not ignore
|
||||
False
|
||||
and len(node) > 1
|
||||
and not items
|
||||
and not attr(item, "expression")
|
||||
# omit the return after While may cause unexpected behavior
|
||||
and not any(isinstance(n, ast.While) for n in node)
|
||||
):
|
||||
# ignore the return
|
||||
continue
|
||||
|
||||
# if all left items are returns
|
||||
if all(
|
||||
isinstance(n, ast.Return) and not attr(n, "expression")
|
||||
for n in items
|
||||
):
|
||||
# ignore all returns
|
||||
rv.append(get_code(item, **kwargs))
|
||||
break
|
||||
|
||||
# if isinstance(item, ast.Init):
|
||||
# if item.priority == 500:
|
||||
# if len(item.block) == 1 and isinstance(item.block[0], ast.Image):
|
||||
# rv.append(get_code(item.block[0]))
|
||||
# continue
|
||||
|
||||
rv.append(get_code(item, **kwargs))
|
||||
return "\n".join(rv)
|
||||
|
||||
# modify node before get code
|
||||
modifier = kwargs.get("modifier")
|
||||
if modifier:
|
||||
modifier(node, **kwargs)
|
||||
return node.get_code(**kwargs)
|
||||
|
||||
|
||||
def get_block_code(node, **kwargs) -> str:
|
||||
"""
|
||||
https://www.renpy.org/doc/html/layeredimage.html#layeredimage
|
||||
"""
|
||||
if isinstance(node, list):
|
||||
return "\n".join(map(lambda x: get_block_code(x, **kwargs), node))
|
||||
lines = []
|
||||
if isinstance(node, tuple):
|
||||
if len(node) == 5:
|
||||
_, _, _, code, block = node
|
||||
elif len(node) == 4:
|
||||
_, _, code, block = node
|
||||
else:
|
||||
raise NotImplementedError
|
||||
lines.append(code)
|
||||
lines.append(indent(get_block_code(block, **kwargs)))
|
||||
else:
|
||||
raise NotImplementedError
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def attr(item, key: str) -> str:
|
||||
if isinstance(item, dict):
|
||||
return item.get(key, None)
|
||||
if hasattr(item, key):
|
||||
return getattr(item, key)
|
||||
return None
|
||||
|
||||
|
||||
def label_code(label: str, child, **kwargs) -> str:
|
||||
"""
|
||||
return a code block with a label
|
||||
example:
|
||||
>>> label_getcode("label foo", None)
|
||||
"label foo"
|
||||
>>> label_getcode("label foo", Expression("bar"))
|
||||
"label foo:
|
||||
bar"
|
||||
"""
|
||||
if not child:
|
||||
return label
|
||||
return f"{label}:\n{indent(get_code(child, **kwargs))}"
|
||||
5
third_party/python/rpycdec/src/rpycdec/__init__.py
vendored
Normal file
5
third_party/python/rpycdec/src/rpycdec/__init__.py
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
from .decompile import decompile, decompile_file
|
||||
from .rpa import create_rpa, extract_rpa
|
||||
from .save import dump_save_info, extract_save, generate_new_key, restore_save
|
||||
from .translate import extract_translations
|
||||
3
third_party/python/rpycdec/src/rpycdec/__main__.py
vendored
Normal file
3
third_party/python/rpycdec/src/rpycdec/__main__.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
193
third_party/python/rpycdec/src/rpycdec/apk.py
vendored
Normal file
193
third_party/python/rpycdec/src/rpycdec/apk.py
vendored
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
APK extraction module for Ren'Py games.
|
||||
|
||||
This module provides functionality to extract Ren'Py game resources from Android APK files.
|
||||
The extraction is done in a streaming fashion to minimize disk I/O and temporary file usage.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from rpycdec.utils import safe_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrependedStream(io.RawIOBase):
|
||||
"""Wrapper to prepend already-read bytes back to stream."""
|
||||
|
||||
def __init__(self, prepend_data, stream):
|
||||
self.prepend_data = prepend_data
|
||||
self.stream = stream
|
||||
self.prepend_pos = 0
|
||||
|
||||
def readable(self):
|
||||
return True
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
result = self.prepend_data[self.prepend_pos :] + self.stream.read()
|
||||
self.prepend_pos = len(self.prepend_data)
|
||||
return result
|
||||
|
||||
result = b""
|
||||
# First read from prepended data
|
||||
if self.prepend_pos < len(self.prepend_data):
|
||||
take = min(size, len(self.prepend_data) - self.prepend_pos)
|
||||
result = self.prepend_data[self.prepend_pos : self.prepend_pos + take]
|
||||
self.prepend_pos += take
|
||||
size -= take
|
||||
|
||||
# Then read from underlying stream
|
||||
if size > 0:
|
||||
result += self.stream.read(size)
|
||||
|
||||
return result
|
||||
|
||||
def readinto(self, b):
|
||||
data = self.read(len(b))
|
||||
b[: len(data)] = data
|
||||
return len(data)
|
||||
|
||||
|
||||
def extract_apk(
|
||||
apk_path: str,
|
||||
output_dir: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Extract Ren'Py game resources from an Android APK file.
|
||||
|
||||
This function directly extracts files from the APK (ZIP) archive,
|
||||
renaming them on-the-fly to remove 'x-' prefixes and organizing
|
||||
them into a standard Ren'Py structure. The private.mp3 archive
|
||||
is extracted in-memory without writing to disk first.
|
||||
|
||||
Args:
|
||||
apk_path: Path to the APK file
|
||||
output_dir: Output directory (default: APK filename without extension)
|
||||
|
||||
Returns:
|
||||
True if extraction was successful, False otherwise
|
||||
"""
|
||||
apk_file = Path(apk_path)
|
||||
|
||||
if not apk_file.exists():
|
||||
logger.error(f"APK file not found: {apk_path}")
|
||||
return False
|
||||
|
||||
if not zipfile.is_zipfile(apk_path):
|
||||
logger.error(f"File is not a valid APK/ZIP file: {apk_path}")
|
||||
return False
|
||||
|
||||
# Determine output directory
|
||||
if output_dir is None:
|
||||
output_path = apk_file.parent / apk_file.stem
|
||||
else:
|
||||
output_path = Path(output_dir)
|
||||
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"Extracting APK: {apk_path}")
|
||||
|
||||
with zipfile.ZipFile(apk_path, "r") as apk:
|
||||
# Get all relevant files (assets and lib)
|
||||
target_files = [f for f in apk.namelist() if f.startswith(("assets/", "lib/"))]
|
||||
|
||||
if not target_files:
|
||||
logger.warning("No assets or lib directory found in APK")
|
||||
return False
|
||||
|
||||
private_mp3_path = None
|
||||
extracted_count = 0
|
||||
|
||||
for file_path in target_files:
|
||||
# Skip directories
|
||||
if file_path.endswith("/"):
|
||||
continue
|
||||
|
||||
if file_path.startswith("assets/"):
|
||||
# Remove 'assets/' prefix
|
||||
relative_path = file_path[7:] # len('assets/') = 7
|
||||
|
||||
# Handle private.mp3 specially - remember path for later streaming extraction
|
||||
if relative_path == "private.mp3":
|
||||
logger.info("Found private.mp3, will extract via streaming...")
|
||||
private_mp3_path = file_path
|
||||
continue
|
||||
|
||||
# Process path: remove x- prefix and apply mappings
|
||||
path_parts = relative_path.split("/")
|
||||
|
||||
# Process all path parts to remove x- prefix
|
||||
for i in range(len(path_parts)):
|
||||
# Remove x- prefix from all parts
|
||||
if path_parts[i].startswith("x-"):
|
||||
path_parts[i] = path_parts[i][2:] # Remove 'x-' prefix
|
||||
|
||||
# Reconstruct path
|
||||
dest_path = output_path / "/".join(path_parts)
|
||||
|
||||
elif file_path.startswith("lib/"):
|
||||
# User request: extract lib to output/lib/android/
|
||||
rel_lib_path = file_path[4:] # len('lib/') = 4
|
||||
dest_path = output_path / "lib" / "android" / rel_lib_path
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
# Create parent directories
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Extract file directly
|
||||
with apk.open(file_path) as src, open(dest_path, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
extracted_count += 1
|
||||
if extracted_count % 100 == 0:
|
||||
logger.info(f"Extracted {extracted_count} files...")
|
||||
|
||||
logger.info(f"Extracted {extracted_count} files")
|
||||
|
||||
# Extract private.mp3 via streaming if found
|
||||
if private_mp3_path:
|
||||
logger.info("Extracting files from private.mp3 archive...")
|
||||
# Extract directly to output root (same level as game/)
|
||||
private_output = output_path
|
||||
private_output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with apk.open(private_mp3_path) as private_stream:
|
||||
# Read magic bytes to detect gzip format
|
||||
magic = private_stream.read(2)
|
||||
|
||||
if not magic.startswith(b"\x1f\x8b"):
|
||||
logger.warning(
|
||||
"private.mp3 is not gzip format, skipping extraction"
|
||||
)
|
||||
else:
|
||||
# Create buffered stream with magic bytes prepended
|
||||
wrapped_stream = io.BufferedReader(
|
||||
PrependedStream(magic, private_stream)
|
||||
)
|
||||
|
||||
with tarfile.open(fileobj=wrapped_stream, mode="r:gz") as tar:
|
||||
# Path traversal protection
|
||||
for member in tar.getmembers():
|
||||
try:
|
||||
safe_path(str(private_output), member.name)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Skipping path traversal attempt in tar: %s",
|
||||
member.name,
|
||||
)
|
||||
continue
|
||||
tar.extract(member, path=private_output)
|
||||
logger.info(
|
||||
f"Extracted {len(tar.getmembers())} files from private.mp3"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully extracted game to: {output_path}")
|
||||
return True
|
||||
317
third_party/python/rpycdec/src/rpycdec/cli.py
vendored
Normal file
317
third_party/python/rpycdec/src/rpycdec/cli.py
vendored
Normal file
@@ -0,0 +1,317 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from rpycdec.apk import extract_apk
|
||||
from rpycdec.decompile import decompile
|
||||
from rpycdec.rpa import create_rpa, extract_rpa
|
||||
from rpycdec.save import dump_save_info, extract_save, generate_new_key, restore_save
|
||||
from rpycdec.translate import extract_translations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def regular_expression(value: str) -> str:
|
||||
"""Validate a command-line regular expression."""
|
||||
try:
|
||||
re.compile(value)
|
||||
except re.error as exc:
|
||||
raise argparse.ArgumentTypeError(f"invalid regular expression: {exc}") from exc
|
||||
return value
|
||||
|
||||
|
||||
def non_empty_suffix(value: str) -> str:
|
||||
"""Validate a command-line file suffix."""
|
||||
if not value:
|
||||
raise argparse.ArgumentTypeError("suffix must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
def decompile_files(srcs: list[str], **kwargs):
|
||||
"""
|
||||
decompile rpyc file or directory.
|
||||
"""
|
||||
for src in srcs:
|
||||
decompile(
|
||||
src,
|
||||
output_path=kwargs.get("output"),
|
||||
dis=kwargs.get("disassemble", False),
|
||||
)
|
||||
|
||||
|
||||
def extract_rpa_files(srcs: list[str], **kwargs):
|
||||
"""
|
||||
extract rpa archive.
|
||||
"""
|
||||
for src in srcs:
|
||||
with open(src, "rb") as f:
|
||||
output_path = kwargs.get("output") or os.path.dirname(src)
|
||||
extract_rpa(
|
||||
f,
|
||||
output_dir=output_path,
|
||||
expressions=kwargs.get("expressions"),
|
||||
suffixes=kwargs.get("suffixes"),
|
||||
)
|
||||
|
||||
|
||||
def create_rpa_archive(srcs: list[str], output: str, **kwargs):
|
||||
"""
|
||||
create rpa archive.
|
||||
"""
|
||||
create_rpa(
|
||||
output,
|
||||
srcs,
|
||||
base_dir=kwargs.get("base"),
|
||||
)
|
||||
|
||||
|
||||
def run_extract_translations(
|
||||
srcs: list[str], language: str, output: str | None = None, **kwargs
|
||||
):
|
||||
"""
|
||||
extract translations from rpy files.
|
||||
"""
|
||||
for src in srcs:
|
||||
# Default output to current directory, extract_translations will create tl/{language}/
|
||||
dest = output or "."
|
||||
extract_translations(
|
||||
game_dir=src,
|
||||
output_dir=dest,
|
||||
language=language,
|
||||
include_dialogues=kwargs.get("include_dialogues", True),
|
||||
include_strings=kwargs.get("include_strings", True),
|
||||
empty_translation=kwargs.get("empty_translation", False),
|
||||
)
|
||||
|
||||
|
||||
def extract_game_from_apk(apk_path: str, **kwargs):
|
||||
"""
|
||||
Extract Ren'Py game from Android APK file.
|
||||
"""
|
||||
extract_apk(
|
||||
apk_path=apk_path,
|
||||
output_dir=kwargs.get("output"),
|
||||
)
|
||||
|
||||
|
||||
SECURITY_WARNING = (
|
||||
"\033[33mSecurity Warning: This tool uses a restricted pickle unpickler with "
|
||||
"whitelist-based class loading to mitigate arbitrary code execution risks. "
|
||||
"However, no safeguard is perfect. Only process files from trusted sources.\033[0m"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
command line tool entry.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
argparser = argparse.ArgumentParser()
|
||||
argparser.add_argument(
|
||||
"--verbose", "-v", action="store_true", help="verbose output"
|
||||
)
|
||||
subparsers = argparser.add_subparsers(
|
||||
title="subcommands", dest="command", help="subcommand help"
|
||||
)
|
||||
|
||||
decompile_parser = subparsers.add_parser("decompile", help="decompile rpyc file")
|
||||
decompile_parser.add_argument("src", nargs="+", help="rpyc file or directory")
|
||||
decompile_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
help="output path",
|
||||
)
|
||||
decompile_parser.add_argument(
|
||||
"--disassemble", "-d", action="store_true", help="disassemble rpyc file"
|
||||
)
|
||||
decompile_parser.set_defaults(
|
||||
func=lambda args: decompile_files(args.src, **vars(args))
|
||||
)
|
||||
|
||||
unrpa_parser = subparsers.add_parser("unrpa", help="extract rpa archive")
|
||||
unrpa_parser.add_argument("file", nargs=1, help="rpa archive")
|
||||
unrpa_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
help="output path",
|
||||
)
|
||||
unrpa_parser.add_argument(
|
||||
"--expression",
|
||||
"-e",
|
||||
dest="expressions",
|
||||
action="append",
|
||||
type=regular_expression,
|
||||
metavar="REGEX",
|
||||
help=(
|
||||
"extract archive paths matching this regular expression; may be repeated"
|
||||
),
|
||||
)
|
||||
unrpa_parser.add_argument(
|
||||
"--suffix",
|
||||
"-s",
|
||||
dest="suffixes",
|
||||
action="extend",
|
||||
nargs="+",
|
||||
type=non_empty_suffix,
|
||||
metavar="SUFFIX",
|
||||
help=(
|
||||
"extract archive paths ending with any of these suffixes "
|
||||
"(case-insensitive); may be repeated"
|
||||
),
|
||||
)
|
||||
unrpa_parser.set_defaults(
|
||||
func=lambda args: extract_rpa_files(args.file, **vars(args))
|
||||
)
|
||||
|
||||
rpa_parser = subparsers.add_parser("rpa", help="create rpa archive")
|
||||
rpa_parser.add_argument("src", nargs="+", help="file or directory to add")
|
||||
rpa_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
required=True,
|
||||
help="output .rpa archive path",
|
||||
)
|
||||
rpa_parser.add_argument(
|
||||
"--base",
|
||||
help=(
|
||||
"base directory for archive names "
|
||||
"(default: source directory for a single directory, otherwise common parent)"
|
||||
),
|
||||
)
|
||||
rpa_parser.set_defaults(
|
||||
func=lambda args: create_rpa_archive(
|
||||
args.src,
|
||||
args.output,
|
||||
base=args.base,
|
||||
)
|
||||
)
|
||||
|
||||
extract_game_parser = subparsers.add_parser(
|
||||
"extract-game", help="extract Ren'Py game from Android APK file"
|
||||
)
|
||||
extract_game_parser.add_argument("apk", help="Android APK file path")
|
||||
extract_game_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
help="output directory (default: APK filename without extension)",
|
||||
)
|
||||
extract_game_parser.set_defaults(
|
||||
func=lambda args: extract_game_from_apk(args.apk, **vars(args))
|
||||
)
|
||||
|
||||
extract_translate_parser = subparsers.add_parser(
|
||||
"extract-translate",
|
||||
help="""extract translations from rpy files and save to tl/<language> directory.""",
|
||||
)
|
||||
extract_translate_parser.add_argument(
|
||||
"src", nargs="+", help="source game directory"
|
||||
)
|
||||
extract_translate_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
help="output directory (default: current directory, creates tl/<language>/)",
|
||||
)
|
||||
extract_translate_parser.add_argument(
|
||||
"--language",
|
||||
"-l",
|
||||
required=True,
|
||||
help="target language code (e.g. chinese, japanese)",
|
||||
)
|
||||
extract_translate_parser.add_argument(
|
||||
"--no-dialogues",
|
||||
action="store_true",
|
||||
help="skip dialogue translations",
|
||||
)
|
||||
extract_translate_parser.add_argument(
|
||||
"--no-strings",
|
||||
action="store_true",
|
||||
help="skip string translations",
|
||||
)
|
||||
extract_translate_parser.add_argument(
|
||||
"--empty",
|
||||
action="store_true",
|
||||
help="generate empty translations (default: copy original)",
|
||||
)
|
||||
extract_translate_parser.set_defaults(
|
||||
func=lambda args: run_extract_translations(
|
||||
args.src,
|
||||
args.language,
|
||||
output=args.output,
|
||||
include_dialogues=not args.no_dialogues,
|
||||
include_strings=not args.no_strings,
|
||||
empty_translation=args.empty,
|
||||
)
|
||||
)
|
||||
|
||||
save_parser = subparsers.add_parser("save", help="save operations")
|
||||
save_subparsers = save_parser.add_subparsers(
|
||||
title="save subcommands", dest="save_command", help="save subcommand help"
|
||||
)
|
||||
save_extract_parser = save_subparsers.add_parser(
|
||||
"extract", help="extract save data to JSON files for editing"
|
||||
)
|
||||
save_extract_parser.add_argument("path", nargs=1, help="save-file path")
|
||||
save_extract_parser.add_argument("dest", nargs="?", help="extract to directory")
|
||||
save_extract_parser.add_argument(
|
||||
"--disassemble", "-d", action="store_true", help="print pickle disassembly"
|
||||
)
|
||||
save_extract_parser.add_argument(
|
||||
"--verbose", "-V", action="store_true", help="verbose class loading output"
|
||||
)
|
||||
save_extract_parser.set_defaults(
|
||||
func=lambda args: extract_save(
|
||||
args.path[0],
|
||||
args.dest or "",
|
||||
dissemble=args.disassemble,
|
||||
verbose=getattr(args, "verbose", False),
|
||||
)
|
||||
)
|
||||
|
||||
save_restore_parser = save_subparsers.add_parser(
|
||||
"restore", help="restore save data from extracted JSON directory"
|
||||
)
|
||||
save_restore_parser.add_argument("path", nargs=1, help="extracted directory")
|
||||
save_restore_parser.add_argument("dest", nargs="?", help="restore to save-file")
|
||||
save_restore_parser.add_argument(
|
||||
"--key", "-k", help="path to security_keys.txt for re-signing"
|
||||
)
|
||||
save_restore_parser.set_defaults(
|
||||
func=lambda args: restore_save(
|
||||
args.path[0], args.dest or "", key_file=args.key or ""
|
||||
)
|
||||
)
|
||||
|
||||
save_info_parser = save_subparsers.add_parser(
|
||||
"info", help="show save file information"
|
||||
)
|
||||
save_info_parser.add_argument("path", nargs=1, help="save-file path")
|
||||
save_info_parser.set_defaults(func=lambda args: dump_save_info(args.path[0]))
|
||||
|
||||
save_genkey_parser = save_subparsers.add_parser(
|
||||
"genkey", help="generate a new signing key (requires ecdsa library)"
|
||||
)
|
||||
save_genkey_parser.add_argument(
|
||||
"output",
|
||||
nargs="?",
|
||||
default="security_keys.txt",
|
||||
help="output key file path (default: security_keys.txt)",
|
||||
)
|
||||
save_genkey_parser.set_defaults(func=lambda args: generate_new_key(args.output))
|
||||
|
||||
args = argparser.parse_args()
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
if not args.command:
|
||||
argparser.print_help()
|
||||
return
|
||||
|
||||
# Show security warning for commands that process pickle data
|
||||
if args.command in ("decompile", "save", "unrpa") and not os.environ.get(
|
||||
"RPYCDEC_NO_WARNING"
|
||||
):
|
||||
print(SECURITY_WARNING, file=sys.stderr)
|
||||
|
||||
args.func(args)
|
||||
58
third_party/python/rpycdec/src/rpycdec/decompile.py
vendored
Normal file
58
third_party/python/rpycdec/src/rpycdec/decompile.py
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Module for decompiling Ren'Py compiled files (.rpyc) back to source code (.rpy)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from renpy import util
|
||||
from rpycdec import stmts, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decompile_file(input_file, output_path=None, **kwargs):
|
||||
"""
|
||||
decompile rpyc file into rpy file and write to output.
|
||||
"""
|
||||
if not output_path:
|
||||
name, _ = os.path.splitext(input_file)
|
||||
output_path = f"{name}.rpy"
|
||||
|
||||
output_dir = os.path.dirname(output_path)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
stmt = stmts.load_file(input_file, **kwargs)
|
||||
code = util.get_code(stmt)
|
||||
except Exception as e:
|
||||
logger.error("decode file %s failed: %s", input_file, e)
|
||||
raise
|
||||
utils.write_file(output_path, code)
|
||||
logger.info("decompile %s -> %s", input_file, output_path)
|
||||
|
||||
|
||||
def decompile(input_path, output_path=None, **kwargs):
|
||||
"""
|
||||
decompile rpyc file or directory into rpy
|
||||
|
||||
Parameters
|
||||
----------
|
||||
input_path : str
|
||||
path to rpyc file or directory contains rpyc files
|
||||
output_path : str, optional
|
||||
output path, by default it's same path of input_path.
|
||||
"""
|
||||
if not os.path.isdir(input_path):
|
||||
if output_path:
|
||||
name, _ = os.path.splitext(os.path.basename(input_path))
|
||||
output_path = os.path.join(output_path, f"{name}.rpy")
|
||||
decompile_file(input_path, output_path, **kwargs)
|
||||
return
|
||||
if not output_path:
|
||||
output_path = input_path
|
||||
for filename in utils.match_files(input_path, r".*\.rpym?c$"):
|
||||
decompile_file(
|
||||
os.path.join(input_path, filename),
|
||||
os.path.join(output_path, filename.removesuffix("c")),
|
||||
**kwargs,
|
||||
)
|
||||
249
third_party/python/rpycdec/src/rpycdec/rpa.py
vendored
Normal file
249
third_party/python/rpycdec/src/rpycdec/rpa.py
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import zlib
|
||||
from collections.abc import Iterable
|
||||
from io import BufferedIOBase
|
||||
from pathlib import Path
|
||||
|
||||
from rpycdec.safe_pickle import rpa_loads
|
||||
from rpycdec.utils import safe_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RPA3_KEY = 0x42424242
|
||||
RPA3_PADDING = b"Made with Ren'Py."
|
||||
RPA3_HEADER_PLACEHOLDER = b"RPA-3.0 XXXXXXXXXXXXXXXX XXXXXXXX\n"
|
||||
|
||||
|
||||
def read_until(data: BufferedIOBase, delimiter: int = 0x00) -> bytes:
|
||||
"""Read bytes from stream until delimiter is found.
|
||||
|
||||
Raises ValueError on unexpected EOF.
|
||||
"""
|
||||
content = bytearray()
|
||||
while True:
|
||||
c = data.read(1)
|
||||
if not c:
|
||||
raise ValueError("Unexpected EOF while reading stream")
|
||||
if c[0] == delimiter:
|
||||
break
|
||||
content += c
|
||||
return bytes(content)
|
||||
|
||||
|
||||
def start_to_bytes(left: list | None) -> bytes:
|
||||
if not left:
|
||||
return b""
|
||||
if isinstance(left[0], bytes):
|
||||
return left[0]
|
||||
return left[0].encode("latin-1")
|
||||
|
||||
|
||||
def normalize_archive_name(name: str) -> str:
|
||||
return name.replace(os.sep, "/")
|
||||
|
||||
|
||||
def collect_rpa_files(
|
||||
sources: list[str],
|
||||
base_dir: str | None = None,
|
||||
output_path: str | None = None,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Collect files to add to an archive.
|
||||
|
||||
Returns ``(archive_name, filesystem_path)`` tuples. Directories contribute
|
||||
all nested files, with deterministic sorting.
|
||||
"""
|
||||
if not sources:
|
||||
raise ValueError("No source files were provided.")
|
||||
|
||||
paths = [Path(src) for src in sources]
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
if not path.is_file() and not path.is_dir():
|
||||
raise ValueError(f"Unsupported source path: {path}")
|
||||
|
||||
if base_dir is not None:
|
||||
base = Path(base_dir).resolve()
|
||||
elif len(paths) == 1 and paths[0].is_dir():
|
||||
base = paths[0].resolve()
|
||||
else:
|
||||
parents = [
|
||||
path.resolve().parent if path.is_file() else path.resolve()
|
||||
for path in paths
|
||||
]
|
||||
base = Path(os.path.commonpath([str(parent) for parent in parents]))
|
||||
|
||||
output = Path(output_path).resolve() if output_path else None
|
||||
entries: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for path in sorted(paths, key=lambda p: str(p)):
|
||||
resolved = path.resolve()
|
||||
candidates = [resolved]
|
||||
if resolved.is_dir():
|
||||
candidates = sorted(
|
||||
(p for p in resolved.rglob("*") if p.is_file()),
|
||||
key=lambda p: str(p),
|
||||
)
|
||||
|
||||
for file_path in candidates:
|
||||
if output is not None and file_path.resolve() == output:
|
||||
logger.info("skipping output archive: %s", file_path)
|
||||
continue
|
||||
|
||||
try:
|
||||
archive_name = normalize_archive_name(
|
||||
str(file_path.resolve().relative_to(base))
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"{file_path} is not under base directory {base}"
|
||||
) from exc
|
||||
|
||||
if archive_name in ("", "."):
|
||||
archive_name = file_path.name
|
||||
|
||||
if archive_name in seen:
|
||||
raise ValueError(f"Duplicate archive path: {archive_name}")
|
||||
|
||||
seen.add(archive_name)
|
||||
entries.append((archive_name, str(file_path)))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def create_rpa(
|
||||
output_path: str,
|
||||
sources: list[str],
|
||||
base_dir: str | None = None,
|
||||
key: int = RPA3_KEY,
|
||||
):
|
||||
"""Create a Ren'Py RPA-3.0 archive from files or directories."""
|
||||
entries = collect_rpa_files(sources, base_dir=base_dir, output_path=output_path)
|
||||
output_dir = os.path.dirname(output_path)
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
index: dict[str, list[tuple[int, int, bytes]]] = {}
|
||||
|
||||
with open(output_path, "wb") as archive:
|
||||
archive.write(RPA3_HEADER_PLACEHOLDER)
|
||||
|
||||
for archive_name, file_path in entries:
|
||||
archive.write(RPA3_PADDING)
|
||||
offset = archive.tell()
|
||||
size = os.path.getsize(file_path)
|
||||
|
||||
with open(file_path, "rb") as source:
|
||||
while True:
|
||||
chunk = source.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
archive.write(chunk)
|
||||
|
||||
index[archive_name] = [(offset ^ key, size ^ key, b"")]
|
||||
logger.info("adding: %s", archive_name)
|
||||
|
||||
index_offset = archive.tell()
|
||||
archive.write(zlib.compress(pickle.dumps(index, pickle.HIGHEST_PROTOCOL)))
|
||||
|
||||
archive.seek(0)
|
||||
archive.write(b"RPA-3.0 %016x %08x\n" % (index_offset, key))
|
||||
|
||||
|
||||
def _compile_file_filters(
|
||||
expressions: str | Iterable[str] | None,
|
||||
suffixes: str | Iterable[str] | None,
|
||||
) -> tuple[list[re.Pattern[str]], tuple[str, ...]]:
|
||||
expression_values = (expressions,) if isinstance(expressions, str) else expressions
|
||||
suffix_values = (suffixes,) if isinstance(suffixes, str) else suffixes
|
||||
patterns = [re.compile(expression) for expression in expression_values or ()]
|
||||
normalized_suffixes = tuple(suffix.casefold() for suffix in suffix_values or ())
|
||||
if any(not suffix for suffix in normalized_suffixes):
|
||||
raise ValueError("RPA file suffixes must not be empty.")
|
||||
return patterns, normalized_suffixes
|
||||
|
||||
|
||||
def _matches_file_filters(
|
||||
filename: str,
|
||||
patterns: list[re.Pattern[str]],
|
||||
suffixes: tuple[str, ...],
|
||||
) -> bool:
|
||||
if not patterns and not suffixes:
|
||||
return True
|
||||
|
||||
# RPA paths conventionally use forward slashes. Normalize legacy archives
|
||||
# so expressions behave consistently on every host platform.
|
||||
archive_path = filename.replace("\\", "/")
|
||||
return any(pattern.search(archive_path) for pattern in patterns) or (
|
||||
bool(suffixes) and archive_path.casefold().endswith(suffixes)
|
||||
)
|
||||
|
||||
|
||||
def extract_rpa(
|
||||
r: BufferedIOBase,
|
||||
output_dir: str | None = None,
|
||||
expressions: str | Iterable[str] | None = None,
|
||||
suffixes: str | Iterable[str] | None = None,
|
||||
):
|
||||
"""Extract files from an RPA archive.
|
||||
|
||||
When *expressions* or *suffixes* are provided, a file is extracted when
|
||||
its normalized archive path matches any regular expression or suffix.
|
||||
Regular expressions use :func:`re.search`; suffix matching ignores case.
|
||||
"""
|
||||
output_dir = output_dir or "."
|
||||
patterns, normalized_suffixes = _compile_file_filters(expressions, suffixes)
|
||||
magic = read_until(r, 0x20)
|
||||
if magic != b"RPA-3.0":
|
||||
raise ValueError("Not a Ren'Py RPA-3.0 archive.")
|
||||
index_offset = int(read_until(r, 0x20), 16)
|
||||
key = int(read_until(r, 0x0A).decode(), 16)
|
||||
|
||||
# read index
|
||||
r.seek(index_offset)
|
||||
index = rpa_loads(zlib.decompress(r.read()))
|
||||
|
||||
for k, v in index.items():
|
||||
index[k] = [
|
||||
(offset ^ key, dlen ^ key, start_to_bytes(left))
|
||||
for offset, dlen, *left in v
|
||||
]
|
||||
|
||||
for filename, entries in index.items():
|
||||
# Handle bytes filenames from Python 2 era archives
|
||||
if isinstance(filename, bytes):
|
||||
filename = filename.decode("utf-8", errors="surrogateescape")
|
||||
|
||||
if not _matches_file_filters(filename, patterns, normalized_suffixes):
|
||||
logger.debug("skipping filtered file: %s", filename)
|
||||
continue
|
||||
|
||||
# Path traversal protection. Resolve the destination before reading
|
||||
# the payload so rejected entries do not consume unnecessary memory.
|
||||
try:
|
||||
dest = safe_path(output_dir, filename)
|
||||
except ValueError:
|
||||
logger.warning("Skipping path traversal attempt: %s", filename)
|
||||
continue
|
||||
|
||||
data = bytearray()
|
||||
for offset, dlen, start in entries:
|
||||
r.seek(offset)
|
||||
block = r.read(dlen)
|
||||
if start:
|
||||
if block.startswith(start):
|
||||
block = block[len(start) :]
|
||||
else:
|
||||
logger.warning(
|
||||
"%s does not start with expected prefix %s", filename, start
|
||||
)
|
||||
data += block
|
||||
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "wb") as f:
|
||||
logger.info("extracting: %s", dest)
|
||||
f.write(data)
|
||||
257
third_party/python/rpycdec/src/rpycdec/safe_pickle.py
vendored
Normal file
257
third_party/python/rpycdec/src/rpycdec/safe_pickle.py
vendored
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Safe pickle unpickling utilities.
|
||||
|
||||
WARNING: Python's pickle format can execute arbitrary code during
|
||||
deserialization. This module provides restricted unpicklers that only
|
||||
allow known-safe classes to be loaded, preventing malicious pickle
|
||||
files from executing harmful code.
|
||||
|
||||
See: https://docs.python.org/3/library/pickle.html#module-pickle
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import pickle
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whitelist definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Standard library modules that are safe to import during unpickling.
|
||||
SAFE_MODULES: set[str] = {
|
||||
"collections",
|
||||
"collections.abc",
|
||||
}
|
||||
|
||||
# Builtin names that are safe to unpickle (data types only).
|
||||
# Dangerous names like eval, exec, __import__, getattr, etc. are excluded.
|
||||
SAFE_BUILTINS: set[str] = {
|
||||
"set",
|
||||
"frozenset",
|
||||
"list",
|
||||
"dict",
|
||||
"tuple",
|
||||
"bytes",
|
||||
"bytearray",
|
||||
"complex",
|
||||
"True",
|
||||
"False",
|
||||
"None",
|
||||
"object",
|
||||
"int",
|
||||
"float",
|
||||
"str",
|
||||
"bool",
|
||||
"slice",
|
||||
"range",
|
||||
"type",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DummyClass — safe placeholder for unknown classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DummyClass(object):
|
||||
"""
|
||||
Safe placeholder class for unpickling unknown classes.
|
||||
|
||||
Handles all pickle reconstruction patterns:
|
||||
- NEWOBJ/REDUCE with arguments (stored as _new_args/_new_kwargs)
|
||||
- BUILD with dict state, (state, slotstate) tuples, or opaque state
|
||||
- List-like append operations (APPENDS opcode)
|
||||
"""
|
||||
|
||||
_state: Any
|
||||
_new_args: tuple[Any, ...] | None
|
||||
_new_kwargs: dict[str, Any] | None
|
||||
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> "DummyClass":
|
||||
self = object.__new__(cls)
|
||||
self._state = None
|
||||
self._new_args = args if args else None
|
||||
self._new_kwargs = kwargs if kwargs else None
|
||||
return self
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
# __new__ already handled storage; avoid overwriting
|
||||
pass
|
||||
|
||||
def append(self, value: Any) -> None:
|
||||
if self._state is None:
|
||||
self._state = []
|
||||
self._state.append(value)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.__dict__[key]
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, DummyClass):
|
||||
return NotImplemented
|
||||
return self.__dict__ == other.__dict__
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
self.__dict__[key] = value
|
||||
|
||||
def __getstate__(self) -> Any:
|
||||
if self._state is not None:
|
||||
return self._state
|
||||
# Exclude internal fields from state
|
||||
return {k: v for k, v in self.__dict__.items() if not k.startswith("_new_")}
|
||||
|
||||
def __setstate__(self, state: Any) -> None:
|
||||
# Handle (state, slotstate) tuple pattern from __slots__ classes
|
||||
slotstate = None
|
||||
if (
|
||||
isinstance(state, tuple)
|
||||
and len(state) == 2
|
||||
and (state[0] is None or isinstance(state[0], dict))
|
||||
and (state[1] is None or isinstance(state[1], dict))
|
||||
):
|
||||
state, slotstate = state
|
||||
|
||||
if isinstance(state, dict):
|
||||
self.__dict__.update(state)
|
||||
elif state is not None:
|
||||
self._state = state
|
||||
|
||||
if slotstate:
|
||||
self.__dict__.update(slotstate)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
cls = type(self)
|
||||
module = getattr(cls, "__module__", "?")
|
||||
name = getattr(cls, "__name__", "?")
|
||||
if self._state is not None:
|
||||
return f"<{module}.{name}: state={self._state!r}>"
|
||||
attrs = {
|
||||
k: v
|
||||
for k, v in self.__dict__.items()
|
||||
if not k.startswith("_new_") and k != "_state"
|
||||
}
|
||||
return f"<{module}.{name}: {attrs}>"
|
||||
|
||||
def get_code(self, **kwargs) -> str:
|
||||
name = getattr(type(self), "__name__", "<?>")
|
||||
logger.warning("Unrecognized node %s in decompile output", name)
|
||||
return f"# <unrecognized: {name}>"
|
||||
|
||||
|
||||
def make_dummy_class(module: str, name: str) -> type:
|
||||
"""Create a named DummyClass subclass for a given module.name."""
|
||||
return type(name, (DummyClass,), {"__module__": module})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SafeUnpickler — base restricted unpickler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SafeUnpickler(pickle.Unpickler):
|
||||
"""Base restricted unpickler with whitelist-based class loading.
|
||||
|
||||
Security model:
|
||||
- ``renpy.*`` / ``store.*``: allowed (our fake packages)
|
||||
- ``builtins`` / ``__builtin__``: only safe data types
|
||||
- ``SAFE_MODULES``: standard library modules known to be safe
|
||||
- Everything else: handled by ``_on_unknown_class()``
|
||||
|
||||
Subclasses can override ``_on_unknown_class()`` to customise behaviour
|
||||
for classes outside the whitelist (raise, create placeholder, etc.).
|
||||
"""
|
||||
|
||||
def find_class(self, module: str, name: str) -> Any:
|
||||
# store.* modules -> DummyClass placeholder
|
||||
if module.startswith("store"):
|
||||
return make_dummy_class(module, name)
|
||||
|
||||
# Our fake renpy package is safe to import
|
||||
if module.startswith("renpy"):
|
||||
try:
|
||||
return super().find_class(module, name)
|
||||
except (ImportError, AttributeError):
|
||||
return self._on_unknown_class(module, name)
|
||||
|
||||
# Whitelisted standard library modules
|
||||
if module in SAFE_MODULES:
|
||||
return super().find_class(module, name)
|
||||
|
||||
# Safe builtins only (excludes eval, exec, __import__, etc.)
|
||||
if module in ("builtins", "__builtin__") and name in SAFE_BUILTINS:
|
||||
return super().find_class(module, name)
|
||||
|
||||
return self._on_unknown_class(module, name)
|
||||
|
||||
def _on_unknown_class(self, module: str, name: str) -> Any:
|
||||
"""Handle a class reference outside the whitelist.
|
||||
|
||||
Default: log a warning and return a DummyClass placeholder.
|
||||
Subclasses may override to raise or return different placeholders.
|
||||
"""
|
||||
logger.warning(
|
||||
f"Unknown class encountered: {module}.{name}. "
|
||||
f"Substituting with DummyClass placeholder. "
|
||||
f"If this is a legitimate Ren'Py class, please report an issue."
|
||||
)
|
||||
return make_dummy_class(module, name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RestrictedUnpickler — only allows primitive/builtin data types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Primitive builtin types that are always safe.
|
||||
_PRIMITIVE_BUILTINS = {
|
||||
"dict",
|
||||
"list",
|
||||
"tuple",
|
||||
"set",
|
||||
"frozenset",
|
||||
"bytes",
|
||||
"bytearray",
|
||||
"str",
|
||||
"int",
|
||||
"float",
|
||||
"bool",
|
||||
"complex",
|
||||
"True",
|
||||
"False",
|
||||
"None",
|
||||
}
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
"""Strictly restricted unpickler that only allows primitive data types.
|
||||
|
||||
Suitable for data that should only contain basic Python types
|
||||
(dict, list, tuple, int, str, bytes, etc.) with no custom classes.
|
||||
|
||||
Used by RPA index parsing and any other context where only
|
||||
primitive data structures are expected.
|
||||
"""
|
||||
|
||||
def find_class(self, module: str, name: str) -> Any:
|
||||
if module in ("builtins", "__builtin__") and name in _PRIMITIVE_BUILTINS:
|
||||
return super().find_class(module, name)
|
||||
|
||||
raise pickle.UnpicklingError(
|
||||
f"Refusing to unpickle non-primitive class: {module}.{name}"
|
||||
)
|
||||
|
||||
|
||||
def safe_loads(data: bytes, **kwargs: Any) -> Any:
|
||||
"""Safely unpickle data using SafeUnpickler.
|
||||
|
||||
Unknown classes are substituted with DummyClass placeholders.
|
||||
"""
|
||||
return SafeUnpickler(io.BytesIO(data), **kwargs).load()
|
||||
|
||||
|
||||
def rpa_loads(data: bytes) -> Any:
|
||||
"""Safely unpickle RPA index data (very restricted, primitives only)."""
|
||||
return RestrictedUnpickler(io.BytesIO(data), encoding="bytes").load()
|
||||
697
third_party/python/rpycdec/src/rpycdec/save.py
vendored
Normal file
697
third_party/python/rpycdec/src/rpycdec/save.py
vendored
Normal file
@@ -0,0 +1,697 @@
|
||||
"""
|
||||
Ren'Py Save File Parser and Writer
|
||||
|
||||
Ren'Py save files are ZIP archives containing:
|
||||
- screenshot.png: Save screenshot
|
||||
- extra_info: UTF-8 encoded save name/description
|
||||
- json: JSON metadata (version, timestamp, etc.)
|
||||
- renpy_version: Ren'Py version string
|
||||
- log: Pickle serialized game state (roots, log)
|
||||
- signatures: Digital signature for integrity verification
|
||||
|
||||
This module provides functionality to:
|
||||
1. Extract save files to JSON for manual editing
|
||||
2. Restore edited JSON back to valid save files
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import pickletools
|
||||
import zipfile
|
||||
from typing import Any
|
||||
|
||||
from rpycdec.safe_pickle import (
|
||||
SAFE_MODULES,
|
||||
SafeUnpickler,
|
||||
)
|
||||
|
||||
|
||||
class DynamicObject:
|
||||
"""
|
||||
A dynamic object that can represent any Ren'Py class during unpickling.
|
||||
Stores the original class name and module for JSON round-trip serialization.
|
||||
|
||||
Unlike ``DummyClass`` (safe_pickle.py) which spreads state into ``__dict__``
|
||||
for attribute access during decompilation, ``DynamicObject`` keeps state
|
||||
as-is in ``_state`` so it can be faithfully serialized to / from JSON.
|
||||
|
||||
Handles all pickle reconstruction patterns:
|
||||
- NEWOBJ/REDUCE with arguments
|
||||
- BUILD with state (dict or opaque)
|
||||
- APPENDS (list-like append)
|
||||
- SETITEMS (dict-like __setitem__)
|
||||
"""
|
||||
|
||||
# Class-level defaults — overridden by type() in _on_unknown_class.
|
||||
# Using class attrs ensures they survive even when pickle skips __init__
|
||||
# (e.g. the NEWOBJ opcode calls __new__ only).
|
||||
_class_name: str = ""
|
||||
_module_name: str = ""
|
||||
_state: Any
|
||||
_items: list[Any] | None
|
||||
_new_args: tuple[Any, ...] | None
|
||||
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> "DynamicObject":
|
||||
self = object.__new__(cls)
|
||||
self._state = None
|
||||
self._items = None
|
||||
self._new_args = args if args else None
|
||||
return self
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
# __new__ already handled initialization; nothing to do.
|
||||
pass
|
||||
|
||||
# -- pickle BUILD --
|
||||
|
||||
def __setstate__(self, state: Any) -> None:
|
||||
self._state = state
|
||||
|
||||
def __getstate__(self) -> Any:
|
||||
return self._state
|
||||
|
||||
# -- pickle APPENDS --
|
||||
|
||||
def append(self, item: Any) -> None:
|
||||
if self._items is None:
|
||||
self._items = []
|
||||
self._items.append(item)
|
||||
|
||||
# -- pickle SETITEMS --
|
||||
|
||||
def __setitem__(self, key: Any, value: Any) -> None:
|
||||
if self._state is None:
|
||||
self._state = {}
|
||||
if isinstance(self._state, dict):
|
||||
self._state[key] = value
|
||||
|
||||
# -- re-pickling / repr --
|
||||
|
||||
def __reduce__(self):
|
||||
"""Support re-pickling by returning the original class info."""
|
||||
return (
|
||||
_reconstruct_object,
|
||||
(self._module_name, self._class_name, self._state),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self._module_name}.{self._class_name}: {self._state}>"
|
||||
|
||||
# -- JSON round-trip helpers --
|
||||
|
||||
def to_json_dict(self) -> dict:
|
||||
"""Convert to a JSON-serializable dictionary."""
|
||||
result: dict[str, Any] = {
|
||||
"__class__": self._class_name,
|
||||
"__module__": self._module_name,
|
||||
"__state__": _to_json_serializable(self._state),
|
||||
}
|
||||
if self._items:
|
||||
result["__items__"] = _to_json_serializable(self._items)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_json_dict(cls, data: dict) -> "DynamicObject":
|
||||
"""Create a DynamicObject from a JSON dictionary."""
|
||||
obj = cls()
|
||||
obj._class_name = data.get("__class__", "")
|
||||
obj._module_name = data.get("__module__", "")
|
||||
obj._state = _from_json_serializable(data.get("__state__"))
|
||||
items = data.get("__items__")
|
||||
if items:
|
||||
obj._items = _from_json_serializable(items)
|
||||
return obj
|
||||
|
||||
|
||||
# Modules allowed for reconstruction (renpy/store fake packages + safe stdlib)
|
||||
_RECONSTRUCT_SAFE_PREFIXES = ("renpy.", "store.")
|
||||
|
||||
|
||||
def _reconstruct_object(module_name: str, class_name: str, state: Any) -> Any:
|
||||
"""Reconstruct an object from its module, class name, and state.
|
||||
|
||||
Only allows importing from renpy.*/store.* (our fake packages) and
|
||||
whitelisted standard library modules. All other modules fall back
|
||||
to DynamicObject.
|
||||
"""
|
||||
# Only allow our fake packages and whitelisted modules
|
||||
is_safe = (
|
||||
any(module_name.startswith(p) for p in _RECONSTRUCT_SAFE_PREFIXES)
|
||||
or module_name in ("renpy", "store")
|
||||
or module_name in SAFE_MODULES
|
||||
)
|
||||
if is_safe:
|
||||
try:
|
||||
module = __import__(module_name, fromlist=[class_name])
|
||||
cls = getattr(module, class_name)
|
||||
obj = object.__new__(cls)
|
||||
if state is not None:
|
||||
if hasattr(obj, "__setstate__"):
|
||||
obj.__setstate__(state)
|
||||
elif isinstance(state, dict):
|
||||
obj.__dict__.update(state)
|
||||
return obj
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# Fall back to DynamicObject
|
||||
obj = DynamicObject()
|
||||
obj._class_name = class_name
|
||||
obj._module_name = module_name
|
||||
obj._state = state
|
||||
return obj
|
||||
|
||||
|
||||
class SaveUnpickler(SafeUnpickler):
|
||||
"""Safe unpickler for Ren'Py save files.
|
||||
|
||||
Extends SafeUnpickler to replace unknown classes with DynamicObject
|
||||
instances (instead of raising) so save data can be serialized to JSON.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file,
|
||||
*,
|
||||
fix_imports: bool = False,
|
||||
encoding: str = "ASCII",
|
||||
errors: str = "strict",
|
||||
verbose: bool = False,
|
||||
):
|
||||
super().__init__(
|
||||
file, fix_imports=fix_imports, encoding=encoding, errors=errors
|
||||
)
|
||||
self.verbose = verbose
|
||||
|
||||
def _on_unknown_class(self, module: str, name: str) -> type:
|
||||
"""Return a DynamicObject subclass instead of raising."""
|
||||
if self.verbose:
|
||||
print(f"Creating dynamic class: {module}.{name}")
|
||||
return type(
|
||||
name,
|
||||
(DynamicObject,),
|
||||
{
|
||||
"__module__": module,
|
||||
"_class_name": name,
|
||||
"_module_name": module,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SavePickler(pickle.Pickler):
|
||||
"""
|
||||
Custom pickler that handles DynamicObject instances by restoring
|
||||
their original class information.
|
||||
"""
|
||||
|
||||
def reducer_override(self, obj):
|
||||
if isinstance(obj, DynamicObject):
|
||||
# Return a reduction that will reconstruct the original class
|
||||
return (
|
||||
_reconstruct_object,
|
||||
(obj._module_name, obj._class_name, obj._state),
|
||||
)
|
||||
# Fall back to default behavior
|
||||
return NotImplemented
|
||||
|
||||
|
||||
def _to_json_serializable(obj: Any) -> Any:
|
||||
"""Convert a Python object to a JSON-serializable structure."""
|
||||
if obj is None or isinstance(obj, (bool, int, float, str)):
|
||||
return obj
|
||||
|
||||
if isinstance(obj, bytes):
|
||||
return {"__bytes__": base64.b64encode(obj).decode("ascii")}
|
||||
|
||||
if isinstance(obj, tuple):
|
||||
return {"__tuple__": [_to_json_serializable(item) for item in obj]}
|
||||
|
||||
if isinstance(obj, list):
|
||||
return [_to_json_serializable(item) for item in obj]
|
||||
|
||||
if isinstance(obj, dict):
|
||||
result = {}
|
||||
for key, value in obj.items():
|
||||
# JSON keys must be strings
|
||||
if isinstance(key, str):
|
||||
json_key = key
|
||||
elif isinstance(key, (int, float, bool)):
|
||||
json_key = f"__key_{type(key).__name__}__:{key}"
|
||||
elif isinstance(key, tuple):
|
||||
json_key = f"__key_tuple__:{json.dumps(_to_json_serializable(key))}"
|
||||
else:
|
||||
json_key = f"__key_repr__:{repr(key)}"
|
||||
result[json_key] = _to_json_serializable(value)
|
||||
return result
|
||||
|
||||
if isinstance(obj, set):
|
||||
return {"__set__": [_to_json_serializable(item) for item in obj]}
|
||||
|
||||
if isinstance(obj, frozenset):
|
||||
return {"__frozenset__": [_to_json_serializable(item) for item in obj]}
|
||||
|
||||
if isinstance(obj, DynamicObject):
|
||||
return obj.to_json_dict()
|
||||
|
||||
# For other objects, try to extract state
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {
|
||||
"__class__": obj.__class__.__name__,
|
||||
"__module__": obj.__class__.__module__,
|
||||
"__state__": _to_json_serializable(obj.__dict__),
|
||||
}
|
||||
|
||||
if hasattr(obj, "__getstate__"):
|
||||
return {
|
||||
"__class__": obj.__class__.__name__,
|
||||
"__module__": obj.__class__.__module__,
|
||||
"__state__": _to_json_serializable(obj.__getstate__()),
|
||||
}
|
||||
|
||||
# Fallback: convert to string representation
|
||||
return {"__repr__": repr(obj), "__type__": type(obj).__name__}
|
||||
|
||||
|
||||
def _from_json_serializable(obj: Any) -> Any:
|
||||
"""Convert a JSON structure back to Python objects."""
|
||||
if obj is None or isinstance(obj, (bool, int, float, str)):
|
||||
return obj
|
||||
|
||||
if isinstance(obj, list):
|
||||
return [_from_json_serializable(item) for item in obj]
|
||||
|
||||
if isinstance(obj, dict):
|
||||
# Check for special types
|
||||
if "__bytes__" in obj:
|
||||
return base64.b64decode(obj["__bytes__"])
|
||||
|
||||
if "__tuple__" in obj:
|
||||
return tuple(_from_json_serializable(item) for item in obj["__tuple__"])
|
||||
|
||||
if "__set__" in obj:
|
||||
return set(_from_json_serializable(item) for item in obj["__set__"])
|
||||
|
||||
if "__frozenset__" in obj:
|
||||
return frozenset(
|
||||
_from_json_serializable(item) for item in obj["__frozenset__"]
|
||||
)
|
||||
|
||||
if "__class__" in obj and "__module__" in obj:
|
||||
return DynamicObject.from_json_dict(obj)
|
||||
|
||||
if "__repr__" in obj:
|
||||
# Cannot reconstruct, return as-is with warning marker
|
||||
return {"__unrecoverable__": obj["__repr__"]}
|
||||
|
||||
# Regular dict with possibly encoded keys
|
||||
result = {}
|
||||
for key, value in obj.items():
|
||||
if key.startswith("__key_int__:"):
|
||||
actual_key = int(key.split(":", 1)[1])
|
||||
elif key.startswith("__key_float__:"):
|
||||
actual_key = float(key.split(":", 1)[1])
|
||||
elif key.startswith("__key_bool__:"):
|
||||
actual_key = key.split(":", 1)[1] == "True"
|
||||
elif key.startswith("__key_tuple__:"):
|
||||
actual_key = tuple(
|
||||
_from_json_serializable(json.loads(key.split(":", 1)[1]))
|
||||
)
|
||||
elif key.startswith("__key_repr__:"):
|
||||
# Cannot fully reconstruct, use string
|
||||
actual_key = key.split(":", 1)[1]
|
||||
else:
|
||||
actual_key = key
|
||||
result[actual_key] = _from_json_serializable(value)
|
||||
return result
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def load_save_zip(file_path: str) -> dict:
|
||||
"""
|
||||
Load all components from a Ren'Py save file.
|
||||
|
||||
Returns a dict with keys:
|
||||
- screenshot: bytes or None
|
||||
- extra_info: str
|
||||
- json: dict (parsed JSON metadata)
|
||||
- renpy_version: str
|
||||
- log: bytes (raw pickle data)
|
||||
- signatures: str
|
||||
"""
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
result = {
|
||||
"screenshot": None,
|
||||
"extra_info": "",
|
||||
"json": {},
|
||||
"renpy_version": "",
|
||||
"log": b"",
|
||||
"signatures": "",
|
||||
}
|
||||
|
||||
namelist = zf.namelist()
|
||||
|
||||
if "screenshot.png" in namelist:
|
||||
result["screenshot"] = zf.read("screenshot.png")
|
||||
|
||||
if "extra_info" in namelist:
|
||||
result["extra_info"] = zf.read("extra_info").decode("utf-8")
|
||||
|
||||
if "json" in namelist:
|
||||
result["json"] = json.loads(zf.read("json").decode("utf-8"))
|
||||
|
||||
if "renpy_version" in namelist:
|
||||
result["renpy_version"] = zf.read("renpy_version").decode("utf-8")
|
||||
|
||||
if "log" in namelist:
|
||||
result["log"] = zf.read("log")
|
||||
|
||||
if "signatures" in namelist:
|
||||
result["signatures"] = zf.read("signatures").decode("utf-8")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_save_log(log_data: bytes, verbose: bool = False) -> tuple[Any, Any]:
|
||||
"""
|
||||
Parse the pickle log data from a save file.
|
||||
|
||||
Returns (roots, log) tuple.
|
||||
"""
|
||||
unpickler = SaveUnpickler(
|
||||
io.BytesIO(log_data),
|
||||
verbose=verbose,
|
||||
encoding="utf-8",
|
||||
errors="surrogateescape",
|
||||
)
|
||||
return unpickler.load()
|
||||
|
||||
|
||||
def serialize_save_log(roots: Any, log: Any) -> bytes:
|
||||
"""
|
||||
Serialize roots and log back to pickle format.
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
pickler = SavePickler(buffer, protocol=2)
|
||||
pickler.dump((roots, log))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def extract_save(
|
||||
file_path: str,
|
||||
extracted_dir: str = "",
|
||||
dissemble: bool = False,
|
||||
verbose: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Extract a Ren'Py save file to a directory with JSON files for editing.
|
||||
|
||||
Creates:
|
||||
- metadata.json: Contains extra_info, json metadata, renpy_version, signatures
|
||||
- roots.json: The game state roots (variables, etc.)
|
||||
- log.json: The game log/history
|
||||
- screenshot.png: The save screenshot (if present)
|
||||
|
||||
Args:
|
||||
file_path: Path to the .save file
|
||||
extracted_dir: Output directory (default: {file_path}.extracted)
|
||||
dissemble: If True, print pickle disassembly to stdout
|
||||
verbose: If True, print verbose class loading info
|
||||
"""
|
||||
if not extracted_dir:
|
||||
extracted_dir = file_path + ".extracted"
|
||||
|
||||
print(f"Extracting save file: {file_path}")
|
||||
print(f"Output directory: {extracted_dir}")
|
||||
|
||||
# Load the save file
|
||||
save_data = load_save_zip(file_path)
|
||||
|
||||
if dissemble:
|
||||
print("\n=== Pickle Disassembly ===")
|
||||
pickletools.dis(save_data["log"])
|
||||
print("=== End Disassembly ===\n")
|
||||
|
||||
# Parse the log data
|
||||
roots, log = parse_save_log(save_data["log"], verbose=verbose)
|
||||
|
||||
# Create output directory
|
||||
os.makedirs(extracted_dir, exist_ok=True)
|
||||
|
||||
# Save metadata
|
||||
metadata = {
|
||||
"extra_info": save_data["extra_info"],
|
||||
"json": save_data["json"],
|
||||
"renpy_version": save_data["renpy_version"],
|
||||
"signatures": save_data["signatures"],
|
||||
}
|
||||
metadata_path = os.path.join(extracted_dir, "metadata.json")
|
||||
with open(metadata_path, "w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
print(" Saved: metadata.json")
|
||||
|
||||
# Save roots
|
||||
roots_path = os.path.join(extracted_dir, "roots.json")
|
||||
with open(roots_path, "w", encoding="utf-8") as f:
|
||||
json.dump(_to_json_serializable(roots), f, indent=2, ensure_ascii=False)
|
||||
print(" Saved: roots.json")
|
||||
|
||||
# Save log
|
||||
log_path = os.path.join(extracted_dir, "log.json")
|
||||
with open(log_path, "w", encoding="utf-8") as f:
|
||||
json.dump(_to_json_serializable(log), f, indent=2, ensure_ascii=False)
|
||||
print(" Saved: log.json")
|
||||
|
||||
# Save screenshot if present
|
||||
if save_data["screenshot"]:
|
||||
screenshot_path = os.path.join(extracted_dir, "screenshot.png")
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(save_data["screenshot"])
|
||||
print(" Saved: screenshot.png")
|
||||
|
||||
print("\nExtraction complete!")
|
||||
print("Edit roots.json or log.json, then use 'restore' to create a new save file.")
|
||||
|
||||
|
||||
def restore_save(
|
||||
extracted_dir: str,
|
||||
output_file: str = "",
|
||||
key_file: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Restore a Ren'Py save file from extracted JSON files.
|
||||
|
||||
Reads:
|
||||
- metadata.json: Contains extra_info, json metadata, renpy_version, signatures
|
||||
- roots.json: The game state roots
|
||||
- log.json: The game log/history
|
||||
- screenshot.png: The save screenshot (optional)
|
||||
|
||||
Args:
|
||||
extracted_dir: Directory containing extracted JSON files
|
||||
output_file: Output save file path (default: {extracted_dir}.save)
|
||||
key_file: Path to security_keys.txt for re-signing (optional)
|
||||
"""
|
||||
if not output_file:
|
||||
output_file = (
|
||||
extracted_dir.rstrip("/").removesuffix(".extracted") + ".restored.save"
|
||||
)
|
||||
|
||||
print(f"Restoring save file from: {extracted_dir}")
|
||||
print(f"Output file: {output_file}")
|
||||
|
||||
# Load metadata
|
||||
metadata_path = os.path.join(extracted_dir, "metadata.json")
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
print(" Loaded: metadata.json")
|
||||
|
||||
# Load roots
|
||||
roots_path = os.path.join(extracted_dir, "roots.json")
|
||||
with open(roots_path, "r", encoding="utf-8") as f:
|
||||
roots_json = json.load(f)
|
||||
roots = _from_json_serializable(roots_json)
|
||||
print(" Loaded: roots.json")
|
||||
|
||||
# Load log
|
||||
log_path = os.path.join(extracted_dir, "log.json")
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
log_json = json.load(f)
|
||||
log = _from_json_serializable(log_json)
|
||||
print(" Loaded: log.json")
|
||||
|
||||
# Load screenshot if present
|
||||
screenshot = None
|
||||
screenshot_path = os.path.join(extracted_dir, "screenshot.png")
|
||||
if os.path.exists(screenshot_path):
|
||||
with open(screenshot_path, "rb") as f:
|
||||
screenshot = f.read()
|
||||
print(" Loaded: screenshot.png")
|
||||
|
||||
# Serialize the game state
|
||||
log_data = serialize_save_log(roots, log)
|
||||
|
||||
# Handle signatures
|
||||
if key_file:
|
||||
print(f" Re-signing with key: {key_file}")
|
||||
signatures = sign_data(log_data, key_file)
|
||||
print(" Generated new signature")
|
||||
else:
|
||||
# Keep original signatures (will be invalid for modified saves)
|
||||
signatures = metadata.get("signatures", "")
|
||||
|
||||
# Create the save file
|
||||
output_file_tmp = output_file + ".tmp"
|
||||
with zipfile.ZipFile(output_file_tmp, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# Screenshot
|
||||
if screenshot:
|
||||
zf.writestr("screenshot.png", screenshot)
|
||||
|
||||
# Extra info
|
||||
zf.writestr("extra_info", metadata.get("extra_info", "").encode("utf-8"))
|
||||
|
||||
# JSON metadata
|
||||
zf.writestr("json", json.dumps(metadata.get("json", {})).encode("utf-8"))
|
||||
|
||||
# Ren'Py version
|
||||
zf.writestr("renpy_version", metadata.get("renpy_version", "").encode("utf-8"))
|
||||
|
||||
# Log data
|
||||
zf.writestr("log", log_data)
|
||||
|
||||
# Signatures
|
||||
zf.writestr("signatures", signatures.encode("utf-8"))
|
||||
|
||||
# Atomic rename
|
||||
if os.path.exists(output_file):
|
||||
os.unlink(output_file)
|
||||
os.rename(output_file_tmp, output_file)
|
||||
|
||||
print("\nRestore complete!")
|
||||
print(f"Save file created: {output_file}")
|
||||
|
||||
if not key_file:
|
||||
print(
|
||||
"\nNote: No signing key provided. Original signatures preserved but invalid."
|
||||
)
|
||||
print("Use --key to provide security_keys.txt for valid signatures.")
|
||||
print("Some games with strict signature checking may reject modified saves.")
|
||||
|
||||
|
||||
def sign_data(log_data: bytes, key_file: str) -> str:
|
||||
"""
|
||||
Sign log data using ECDSA keys from a Ren'Py security_keys.txt file.
|
||||
|
||||
The key file is typically located at:
|
||||
<game_save_dir>/tokens/security_keys.txt
|
||||
|
||||
Args:
|
||||
log_data: The pickle-serialized game state (log) data
|
||||
key_file: Path to security_keys.txt containing the signing key
|
||||
|
||||
Returns:
|
||||
Signature string in Ren'Py format
|
||||
"""
|
||||
try:
|
||||
import ecdsa
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"ecdsa library required for signing. Install with: pip install ecdsa"
|
||||
)
|
||||
|
||||
def encode_line(kind: str, key: bytes, sig: bytes = b"") -> str:
|
||||
"""Encode a signature line in Ren'Py format."""
|
||||
key_b64 = base64.b64encode(key).decode("ascii")
|
||||
if sig:
|
||||
sig_b64 = base64.b64encode(sig).decode("ascii")
|
||||
return f"{kind} {key_b64} {sig_b64}\n"
|
||||
return f"{kind} {key_b64}\n"
|
||||
|
||||
def decode_line(line: str) -> tuple[str, bytes, bytes]:
|
||||
"""Decode a signature line from Ren'Py format."""
|
||||
parts = line.strip().split()
|
||||
if len(parts) < 2:
|
||||
return "", b"", b""
|
||||
kind = parts[0]
|
||||
key = base64.b64decode(parts[1])
|
||||
sig = base64.b64decode(parts[2]) if len(parts) > 2 else b""
|
||||
return kind, key, sig
|
||||
|
||||
signatures = ""
|
||||
|
||||
with open(key_file, "r") as f:
|
||||
for line in f:
|
||||
kind, key_der, _ = decode_line(line)
|
||||
if kind == "signing-key":
|
||||
try:
|
||||
sk = ecdsa.SigningKey.from_der(key_der)
|
||||
if sk is not None and sk.verifying_key is not None:
|
||||
sig = sk.sign(log_data)
|
||||
signatures += encode_line(
|
||||
"signature", sk.verifying_key.to_der(), sig
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to sign with key: {e}")
|
||||
|
||||
return signatures
|
||||
|
||||
|
||||
def generate_new_key(key_file: str) -> str:
|
||||
"""
|
||||
Generate a new ECDSA signing key and save to file.
|
||||
|
||||
This creates a new security_keys.txt that can be used to sign saves.
|
||||
Note: Saves signed with this key will trigger a trust dialog in the game.
|
||||
|
||||
Args:
|
||||
key_file: Path to save the new security_keys.txt
|
||||
|
||||
Returns:
|
||||
Path to the created key file
|
||||
"""
|
||||
try:
|
||||
import ecdsa
|
||||
except ImportError:
|
||||
raise ImportError("ecdsa library required. Install with: pip install ecdsa")
|
||||
|
||||
sk = ecdsa.SigningKey.generate(curve=ecdsa.NIST256p)
|
||||
vk = sk.verifying_key
|
||||
|
||||
if vk is not None:
|
||||
sk_b64 = base64.b64encode(sk.to_der()).decode("ascii")
|
||||
vk_b64 = base64.b64encode(vk.to_der()).decode("ascii")
|
||||
line = f"signing-key {sk_b64} {vk_b64}\n"
|
||||
|
||||
os.makedirs(os.path.dirname(key_file) or ".", exist_ok=True)
|
||||
with open(key_file, "w") as f:
|
||||
f.write(line)
|
||||
|
||||
print(f"Generated new signing key: {key_file}")
|
||||
return key_file
|
||||
|
||||
raise RuntimeError("Failed to generate signing key")
|
||||
|
||||
|
||||
def dump_save_info(file_path: str):
|
||||
"""
|
||||
Print summary information about a save file.
|
||||
"""
|
||||
save_data = load_save_zip(file_path)
|
||||
|
||||
print(f"Save file: {file_path}")
|
||||
print("\n=== Metadata ===")
|
||||
print(f"Extra info: {save_data['extra_info']}")
|
||||
print(f"Ren'Py version: {save_data['renpy_version']}")
|
||||
print("\n=== JSON Metadata ===")
|
||||
for key, value in save_data["json"].items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
print("\n=== Contents ===")
|
||||
print(f"Screenshot: {'Yes' if save_data['screenshot'] else 'No'}")
|
||||
print(f"Log data size: {len(save_data['log'])} bytes")
|
||||
print(f"Has signatures: {'Yes' if save_data['signatures'] else 'No'}")
|
||||
87
third_party/python/rpycdec/src/rpycdec/stmts.py
vendored
Normal file
87
third_party/python/rpycdec/src/rpycdec/stmts.py
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
import io
|
||||
import logging
|
||||
import pickletools
|
||||
import struct
|
||||
import zlib
|
||||
from os import path
|
||||
|
||||
from renpy.ast import Node
|
||||
from rpycdec.safe_pickle import SafeUnpickler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A string at the start of each rpycv2 file.
|
||||
RPYC2_HEADER = b"RENPY RPC2"
|
||||
|
||||
|
||||
def read_rpyc_data(file: io.BufferedReader, slot):
|
||||
"""
|
||||
Reads the binary data from `slot` in a .rpyc (v1 or v2) file. Returns
|
||||
the data if the slot exists, or None if the slot does not exist.
|
||||
"""
|
||||
header_data = file.read(1024)
|
||||
# Legacy path.
|
||||
if header_data[: len(RPYC2_HEADER)] != RPYC2_HEADER:
|
||||
if slot != 1:
|
||||
return None
|
||||
file.seek(0)
|
||||
data = file.read()
|
||||
return zlib.decompress(data)
|
||||
# RPYC2 path.
|
||||
pos = len(RPYC2_HEADER)
|
||||
while True:
|
||||
header_slot, start, length = struct.unpack("III", header_data[pos : pos + 12])
|
||||
if slot == header_slot:
|
||||
break
|
||||
if header_slot == 0:
|
||||
return None
|
||||
pos += 12
|
||||
file.seek(start)
|
||||
data = file.read(length)
|
||||
return zlib.decompress(data)
|
||||
|
||||
|
||||
def load(
|
||||
data: io.BufferedReader, slots: list[int] | None = None, **kwargs
|
||||
) -> list[Node] | None:
|
||||
"""Load Ren'Py AST from a .rpyc file.
|
||||
|
||||
Tries each slot in order. Slot 1 is the original script,
|
||||
slot 2 is the pre-translated version.
|
||||
"""
|
||||
if slots is None:
|
||||
slots = [1, 2]
|
||||
# 1 is statements before translation, 2 is after translation.
|
||||
for slot in slots:
|
||||
try:
|
||||
bindata = read_rpyc_data(data, slot)
|
||||
if not bindata:
|
||||
continue
|
||||
|
||||
if kwargs.get("dis", False):
|
||||
logger.info("Disassembling rpyc file...")
|
||||
pickletools.dis(bindata)
|
||||
|
||||
unpickler = SafeUnpickler(
|
||||
io.BytesIO(bindata), encoding="utf-8", errors="surrogateescape"
|
||||
)
|
||||
metadata, stmts = unpickler.load()
|
||||
return stmts
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read slot {slot}: {e}")
|
||||
data.seek(0)
|
||||
continue
|
||||
raise ValueError("Unsupported file format or invalid file")
|
||||
|
||||
|
||||
def load_file(filename, **kwargs) -> list[Node] | None:
|
||||
"""Load Ren'Py AST from a .rpyc/.rpymc file."""
|
||||
ext = path.splitext(filename)[1]
|
||||
if ext in [".rpy", ".rpym"]:
|
||||
raise NotImplementedError(
|
||||
"Parsing .rpy files is not supported. Use renpy.parser.parse() from Ren'Py's SDK."
|
||||
)
|
||||
# slot 2 is for pre-translated scripts, slot 1 is for normal scripts
|
||||
slots = [2, 1] if kwargs.get("pre_translated", False) else [1, 2]
|
||||
with open(filename, "rb") as file:
|
||||
return load(file, slots=slots, **kwargs)
|
||||
473
third_party/python/rpycdec/src/rpycdec/translate.py
vendored
Normal file
473
third_party/python/rpycdec/src/rpycdec/translate.py
vendored
Normal file
@@ -0,0 +1,473 @@
|
||||
import collections
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import renpy.ast
|
||||
from rpycdec import stmts, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Translation Extraction Data Structures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class DialogueTranslation:
|
||||
"""Dialogue translation item - generates translate {identifier}: format"""
|
||||
|
||||
identifier: str # Translation identifier e.g. "start_a1b2c3d4"
|
||||
filename: str # Source file
|
||||
linenumber: int # Line number
|
||||
who: Optional[str] # Speaker
|
||||
what: str # Dialogue content
|
||||
code: str # Original code (for comments)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StringTranslation:
|
||||
"""String translation item - generates translate strings: format"""
|
||||
|
||||
filename: str
|
||||
linenumber: int
|
||||
text: str # Text to translate
|
||||
source: str = "string" # Source: "menu", "string", "screen", etc.
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationExtractor:
|
||||
"""Extract translation content from AST"""
|
||||
|
||||
label: Optional[str] = None
|
||||
identifiers: set[str] = field(default_factory=set)
|
||||
dialogues: list[DialogueTranslation] = field(default_factory=list)
|
||||
strings: list[StringTranslation] = field(default_factory=list)
|
||||
_seen_strings: set[str] = field(default_factory=set)
|
||||
|
||||
def extract_file(self, filename: str, nodes: list[renpy.ast.Node]) -> None:
|
||||
"""Extract translations from a single file"""
|
||||
for node in self._walk_nodes(nodes):
|
||||
self._process_node(node, filename)
|
||||
|
||||
def _walk_nodes(self, nodes: list[renpy.ast.Node]) -> Iterator[renpy.ast.Node]:
|
||||
"""Recursively walk all nodes"""
|
||||
if not nodes:
|
||||
return
|
||||
|
||||
for node in nodes:
|
||||
yield node
|
||||
|
||||
# Handle different node types - each node type should only be processed once
|
||||
if isinstance(node, renpy.ast.If):
|
||||
# If node has entries with blocks
|
||||
for _, block in node.entries:
|
||||
if block:
|
||||
yield from self._walk_nodes(block)
|
||||
|
||||
elif isinstance(node, renpy.ast.Menu):
|
||||
# Menu node has items with blocks
|
||||
for _, _, block in node.items:
|
||||
if block:
|
||||
yield from self._walk_nodes(block)
|
||||
|
||||
elif isinstance(node, renpy.ast.UserStatement):
|
||||
# UserStatement may have code_block and subparses
|
||||
if hasattr(node, "code_block") and node.code_block:
|
||||
yield from self._walk_nodes(node.code_block)
|
||||
if hasattr(node, "subparses") and node.subparses:
|
||||
for subparse in node.subparses:
|
||||
if hasattr(subparse, "block") and subparse.block:
|
||||
yield from self._walk_nodes(subparse.block)
|
||||
|
||||
elif hasattr(node, "block") and node.block:
|
||||
# Generic block handling for Label, While, Init, Translate, etc.
|
||||
yield from self._walk_nodes(node.block)
|
||||
|
||||
def _process_node(self, node: renpy.ast.Node, filename: str) -> None:
|
||||
"""Process a single node"""
|
||||
# Track label
|
||||
if isinstance(node, renpy.ast.Label):
|
||||
if not getattr(node, "hide", False):
|
||||
name = (
|
||||
node.get_name()
|
||||
if hasattr(node, "get_name")
|
||||
else getattr(node, "name", None)
|
||||
)
|
||||
if name and isinstance(name, str) and not name.startswith("_"):
|
||||
self.label = name
|
||||
|
||||
# Say node -> dialogue translation
|
||||
if isinstance(node, renpy.ast.Say):
|
||||
self._extract_say(node, filename)
|
||||
|
||||
# TranslateSay node - already translated, skip
|
||||
elif isinstance(node, renpy.ast.TranslateSay):
|
||||
pass # Already a translation node, no need to extract
|
||||
|
||||
# Menu node -> string translation
|
||||
elif isinstance(node, renpy.ast.Menu):
|
||||
self._extract_menu(node, filename)
|
||||
|
||||
# UserStatement node -> string translation
|
||||
elif isinstance(node, renpy.ast.UserStatement):
|
||||
self._extract_user_statement(node, filename)
|
||||
|
||||
def _extract_say(self, node: renpy.ast.Say, filename: str) -> None:
|
||||
"""Extract Say node"""
|
||||
what = getattr(node, "what", None)
|
||||
if not what:
|
||||
return
|
||||
|
||||
identifier = self._generate_identifier(node)
|
||||
who = getattr(node, "who", None)
|
||||
|
||||
self.dialogues.append(
|
||||
DialogueTranslation(
|
||||
identifier=identifier,
|
||||
filename=filename,
|
||||
linenumber=node.linenumber,
|
||||
who=who,
|
||||
what=what,
|
||||
code=node.get_code(),
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_menu(self, node: renpy.ast.Menu, filename: str) -> None:
|
||||
"""Extract Menu node options"""
|
||||
items = getattr(node, "items", [])
|
||||
for caption, condition, block in items:
|
||||
if caption and caption not in self._seen_strings:
|
||||
self._seen_strings.add(caption)
|
||||
# Calculate correct line number
|
||||
if block:
|
||||
line = block[0].linenumber - 1
|
||||
else:
|
||||
line = node.linenumber
|
||||
|
||||
self.strings.append(
|
||||
StringTranslation(
|
||||
filename=filename,
|
||||
linenumber=line,
|
||||
text=caption,
|
||||
source="menu",
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_user_statement(
|
||||
self, node: renpy.ast.UserStatement, filename: str
|
||||
) -> None:
|
||||
"""Extract strings from UserStatement node"""
|
||||
try:
|
||||
translation_strings = node.get_translation_strings()
|
||||
for item in translation_strings:
|
||||
if isinstance(item, tuple):
|
||||
line, text = item
|
||||
else:
|
||||
line, text = node.linenumber, item
|
||||
|
||||
if text and text not in self._seen_strings:
|
||||
self._seen_strings.add(text)
|
||||
self.strings.append(
|
||||
StringTranslation(
|
||||
filename=filename,
|
||||
linenumber=line,
|
||||
text=text,
|
||||
source="user_statement",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass # Some UserStatements may not implement get_translation_strings
|
||||
|
||||
def _generate_identifier(self, node: renpy.ast.Node) -> str:
|
||||
"""Generate translation identifier (Ren'Py compatible)"""
|
||||
# Check for explicit identifier
|
||||
explicit_id = getattr(node, "identifier", None)
|
||||
if explicit_id and getattr(node, "explicit_identifier", False):
|
||||
if explicit_id not in self.identifiers:
|
||||
self.identifiers.add(explicit_id)
|
||||
return explicit_id
|
||||
|
||||
# Calculate MD5
|
||||
code = node.get_code()
|
||||
md5 = hashlib.md5()
|
||||
md5.update((code + "\r\n").encode("utf-8"))
|
||||
digest = md5.hexdigest()[:8]
|
||||
|
||||
identifier = unique_identifier(self.label, digest, self.identifiers)
|
||||
self.identifiers.add(identifier)
|
||||
return identifier
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def unique_identifier(
|
||||
label: str | None, digest: str, existing_identifiers: set | None = None
|
||||
) -> str:
|
||||
"""Generate a unique translation identifier"""
|
||||
if existing_identifiers is None:
|
||||
existing_identifiers = set()
|
||||
if label is None:
|
||||
base = digest
|
||||
else:
|
||||
base = label.replace(".", "_") + "_" + digest
|
||||
i = 0
|
||||
suffix = ""
|
||||
while True:
|
||||
identifier = base + suffix
|
||||
if identifier not in existing_identifiers:
|
||||
break
|
||||
i += 1
|
||||
suffix = "_{0}".format(i)
|
||||
return identifier
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Translation File Generation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def quote_unicode(s: str) -> str:
|
||||
"""Escape special characters in string"""
|
||||
s = s.replace("\\", "\\\\")
|
||||
s = s.replace('"', '\\"')
|
||||
s = s.replace("\a", "\\a")
|
||||
s = s.replace("\b", "\\b")
|
||||
s = s.replace("\f", "\\f")
|
||||
s = s.replace("\n", "\\n")
|
||||
s = s.replace("\r", "\\r")
|
||||
s = s.replace("\t", "\\t")
|
||||
s = s.replace("\v", "\\v")
|
||||
return s
|
||||
|
||||
|
||||
def elide_filename(filename: str, game_dir: str = "") -> str:
|
||||
"""Simplify filename for comments"""
|
||||
if game_dir and filename.startswith(game_dir):
|
||||
filename = filename[len(game_dir) :].lstrip(os.sep)
|
||||
# Convert .rpyc to .rpy for display
|
||||
if filename.endswith(".rpyc"):
|
||||
filename = filename[:-1]
|
||||
elif filename.endswith(".rpymc"):
|
||||
filename = filename[:-1]
|
||||
return filename
|
||||
|
||||
|
||||
def get_translation_filename(source_filename: str) -> str:
|
||||
"""Generate translation filename from source filename, preserving directory structure"""
|
||||
# Remove .rpyc/.rpymc compiled suffix to get the source .rpy/.rpym extension
|
||||
if source_filename.endswith(".rpyc"):
|
||||
return source_filename[:-1]
|
||||
elif source_filename.endswith(".rpymc"):
|
||||
return source_filename[:-1]
|
||||
return source_filename
|
||||
|
||||
|
||||
def write_dialogue_translations(
|
||||
dialogues: list[DialogueTranslation],
|
||||
output_dir: str,
|
||||
language: str,
|
||||
game_dir: str = "",
|
||||
empty_translation: bool = True,
|
||||
filter_func: Optional[Callable[[str], str]] = None,
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
Generate dialogue translation files.
|
||||
|
||||
Returns:
|
||||
dict: {filename: count} number of translations written per file
|
||||
"""
|
||||
# Group by source file
|
||||
by_file: dict[str, list[DialogueTranslation]] = collections.defaultdict(list)
|
||||
for d in dialogues:
|
||||
tl_filename = get_translation_filename(d.filename)
|
||||
by_file[tl_filename].append(d)
|
||||
|
||||
result = {}
|
||||
|
||||
for tl_filename, items in by_file.items():
|
||||
tl_path = os.path.join(output_dir, tl_filename)
|
||||
os.makedirs(
|
||||
os.path.dirname(tl_path) if os.path.dirname(tl_path) else output_dir,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
with open(tl_path, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"# TODO: Translation updated at {datetime.datetime.now().isoformat()}\n\n"
|
||||
)
|
||||
|
||||
for item in items:
|
||||
# Write source file location comment
|
||||
elided = elide_filename(item.filename, game_dir)
|
||||
f.write(f"# {elided}:{item.linenumber}\n")
|
||||
|
||||
# Write translate block
|
||||
f.write(f"translate {language} {item.identifier}:\n\n")
|
||||
|
||||
# Write original code as comment
|
||||
f.write(f" # {item.code}\n")
|
||||
|
||||
# Write translation content
|
||||
if empty_translation:
|
||||
new_what = ""
|
||||
elif filter_func:
|
||||
new_what = filter_func(item.what)
|
||||
else:
|
||||
new_what = item.what
|
||||
|
||||
# Generate translated code
|
||||
if item.who:
|
||||
f.write(f' {item.who} "{quote_unicode(new_what)}"\n')
|
||||
else:
|
||||
f.write(f' "{quote_unicode(new_what)}"\n')
|
||||
|
||||
f.write("\n")
|
||||
|
||||
result[tl_filename] = len(items)
|
||||
logger.info(f"Wrote {len(items)} dialogue translations to {tl_path}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def write_string_translations(
|
||||
strings: list[StringTranslation],
|
||||
output_dir: str,
|
||||
language: str,
|
||||
game_dir: str = "",
|
||||
empty_translation: bool = True,
|
||||
filter_func: Optional[Callable[[str], str]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Generate string translation file.
|
||||
|
||||
Returns:
|
||||
int: number of translations written
|
||||
"""
|
||||
if not strings:
|
||||
return 0
|
||||
|
||||
tl_path = os.path.join(output_dir, "strings.rpy")
|
||||
|
||||
with open(tl_path, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"# TODO: Translation updated at {datetime.datetime.now().isoformat()}\n\n"
|
||||
)
|
||||
f.write(f"translate {language} strings:\n\n")
|
||||
|
||||
for item in strings:
|
||||
# Write source file location comment
|
||||
elided = elide_filename(item.filename, game_dir)
|
||||
f.write(f" # {elided}:{item.linenumber}\n")
|
||||
|
||||
# Write old/new
|
||||
old_text = quote_unicode(item.text)
|
||||
if empty_translation:
|
||||
new_text = ""
|
||||
elif filter_func:
|
||||
new_text = quote_unicode(filter_func(item.text))
|
||||
else:
|
||||
new_text = old_text
|
||||
|
||||
f.write(f' old "{old_text}"\n')
|
||||
f.write(f' new "{new_text}"\n\n')
|
||||
|
||||
logger.info(f"Wrote {len(strings)} string translations to {tl_path}")
|
||||
return len(strings)
|
||||
|
||||
|
||||
def extract_translations(
|
||||
game_dir: str,
|
||||
output_dir: str,
|
||||
language: str,
|
||||
*,
|
||||
include_dialogues: bool = True,
|
||||
include_strings: bool = True,
|
||||
empty_translation: bool = True,
|
||||
filter_func: Optional[Callable[[str], str]] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Extract translation templates from .rpyc files.
|
||||
|
||||
Args:
|
||||
game_dir: Game directory (containing .rpyc files)
|
||||
output_dir: Output directory (will create tl/{language}/ structure)
|
||||
language: Target language code e.g. "chinese", "japanese"
|
||||
include_dialogues: Whether to extract dialogue translations
|
||||
include_strings: Whether to extract string translations
|
||||
empty_translation: True=new is empty, False=new copies old
|
||||
filter_func: Optional text filter/transform function
|
||||
|
||||
Returns:
|
||||
dict: Extraction statistics
|
||||
"""
|
||||
# Create output directory tl/{language}/
|
||||
tl_output_dir = os.path.join(output_dir, "tl", language)
|
||||
os.makedirs(tl_output_dir, exist_ok=True)
|
||||
|
||||
# Find all .rpyc/.rpymc files
|
||||
matches = utils.match_files(game_dir, r".*\.rpym?c$")
|
||||
logger.info(f"Found {len(matches)} rpyc files in {game_dir}")
|
||||
|
||||
# Create extractor
|
||||
extractor = TranslationExtractor()
|
||||
|
||||
# Process all files
|
||||
for filename in matches:
|
||||
filepath = os.path.join(game_dir, filename)
|
||||
logger.info(f"Processing {filename}")
|
||||
|
||||
try:
|
||||
loaded_stmts = stmts.load_file(filepath)
|
||||
if loaded_stmts:
|
||||
extractor.extract_file(filename, loaded_stmts)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process {filename}: {e}")
|
||||
continue
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
"files_processed": len(matches),
|
||||
"dialogues_found": len(extractor.dialogues),
|
||||
"strings_found": len(extractor.strings),
|
||||
"dialogues_written": 0,
|
||||
"strings_written": 0,
|
||||
}
|
||||
|
||||
# Generate translation files
|
||||
if include_dialogues and extractor.dialogues:
|
||||
dialogue_stats = write_dialogue_translations(
|
||||
extractor.dialogues,
|
||||
tl_output_dir,
|
||||
language,
|
||||
game_dir=game_dir,
|
||||
empty_translation=empty_translation,
|
||||
filter_func=filter_func,
|
||||
)
|
||||
stats["dialogues_written"] = sum(dialogue_stats.values())
|
||||
stats["dialogue_files"] = dialogue_stats
|
||||
|
||||
if include_strings and extractor.strings:
|
||||
stats["strings_written"] = write_string_translations(
|
||||
extractor.strings,
|
||||
tl_output_dir,
|
||||
language,
|
||||
game_dir=game_dir,
|
||||
empty_translation=empty_translation,
|
||||
filter_func=filter_func,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Extraction complete: {stats['dialogues_written']} dialogues, "
|
||||
f"{stats['strings_written']} strings written to {tl_output_dir}"
|
||||
)
|
||||
|
||||
return stats
|
||||
56
third_party/python/rpycdec/src/rpycdec/utils.py
vendored
Normal file
56
third_party/python/rpycdec/src/rpycdec/utils.py
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def safe_path(base_dir: str, filename: str) -> str:
|
||||
"""Resolve *filename* under *base_dir* and guard against path traversal.
|
||||
|
||||
Returns the resolved absolute path. Raises ``ValueError`` if the
|
||||
resulting path escapes *base_dir*.
|
||||
"""
|
||||
root = os.path.realpath(base_dir)
|
||||
dest = os.path.realpath(os.path.join(base_dir, filename))
|
||||
# dest must be inside root (or equal to root itself)
|
||||
if dest != root and not dest.startswith(root + os.sep):
|
||||
raise ValueError(f"Path traversal detected: {filename!r}")
|
||||
return dest
|
||||
|
||||
|
||||
def write_file(filename: str, data: str):
|
||||
"""
|
||||
write data to file
|
||||
"""
|
||||
dir = os.path.dirname(filename)
|
||||
if dir and not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
with open(filename, "w", encoding="utf-8") as file:
|
||||
file.write(data)
|
||||
|
||||
|
||||
def match_files(base_dir: str, pattern: str) -> list[str]:
|
||||
"""
|
||||
match files in dir with regex pattern
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base_dir : str
|
||||
directory to find in
|
||||
pattern : str
|
||||
regex pattern
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
matched filenames relative to base_dir
|
||||
"""
|
||||
|
||||
if pattern == "":
|
||||
pattern = ".*"
|
||||
results = []
|
||||
matched = re.compile(pattern)
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for filename in files:
|
||||
filename = os.path.relpath(os.path.join(root, filename), base_dir)
|
||||
if matched.match(filename):
|
||||
results.append(filename)
|
||||
return results
|
||||
1
third_party/python/rpycdec/src/store/__init__.py
vendored
Normal file
1
third_party/python/rpycdec/src/store/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Hover = ''
|
||||
22
third_party/python/rpycdec/src/store/layeredimage.py
vendored
Normal file
22
third_party/python/rpycdec/src/store/layeredimage.py
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
class RawLayeredImage:
|
||||
pass
|
||||
|
||||
|
||||
class RawAlways:
|
||||
pass
|
||||
|
||||
|
||||
class RawAttributeGroup:
|
||||
pass
|
||||
|
||||
|
||||
class RawAttribute:
|
||||
pass
|
||||
|
||||
|
||||
class RawConditionGroup:
|
||||
pass
|
||||
|
||||
|
||||
class RawCondition:
|
||||
pass
|
||||
Reference in New Issue
Block a user