1
0
mirror of synced 2024-11-24 14:30:11 +01:00
bemaniutils/bemani/format/afp/render.py

1506 lines
63 KiB
Python

from typing import Any, Dict, Generator, List, Set, Tuple, Optional, Union
from PIL import Image # type: ignore
from .blend import affine_composite
from .swf import (
SWF,
Frame,
Tag,
AP2ShapeTag,
AP2DefineSpriteTag,
AP2PlaceObjectTag,
AP2RemoveObjectTag,
AP2DoActionTag,
AP2DefineFontTag,
AP2DefineEditTextTag,
AP2PlaceCameraTag,
AP2ImageTag,
)
from .decompile import ByteCode
from .types import (
Color,
Matrix,
Point,
Rectangle,
AP2Trigger,
AP2Action,
PushAction,
StoreRegisterAction,
StringConstant,
Register,
NULL,
UNDEFINED,
GLOBAL,
ROOT,
PARENT,
THIS,
CLIP,
)
from .geo import Shape, DrawParams
from .util import VerboseOutput
class RegisteredClip:
# A movie clip that we are rendering, frame by frame. These are manifest by the root
# SWF as well as AP2DefineSpriteTags which are essentially embedded movie clips. The
# tag_id is the AP2DefineSpriteTag that created us, or None if this is the clip for
# the root of the movie.
def __init__(self, tag_id: Optional[int], frames: List[Frame], tags: List[Tag], labels: Dict[str, int]) -> None:
self.tag_id = tag_id
self.frames = frames
self.tags = tags
self.labels = labels
def __repr__(self) -> str:
return f"RegisteredClip(tag_id={self.tag_id})"
class RegisteredShape:
# A shape that we are rendering, as placed by some placed clip somewhere.
def __init__(self, tag_id: int, vertex_points: List[Point], tex_points: List[Point], tex_colors: List[Color], draw_params: List[DrawParams]) -> None:
self.tag_id = tag_id
self.vertex_points: List[Point] = vertex_points
self.tex_points: List[Point] = tex_points
self.tex_colors: List[Color] = tex_colors
self.draw_params: List[DrawParams] = draw_params
self.rectangle: Optional[Image.image] = None
def __repr__(self) -> str:
return f"RegisteredShape(tag_id={self.tag_id}, vertex_points={self.vertex_points}, tex_points={self.tex_points}, tex_colors={self.tex_colors}, draw_params={self.draw_params})"
class RegisteredImage:
# An image that we should draw directly.
def __init__(self, tag_id: int, reference: str) -> None:
self.tag_id = tag_id
self.reference = reference
def __repr__(self) -> str:
return f"RegisteredImage(tag_id={self.tag_id}, reference={self.reference})"
class RegisteredDummy:
# An imported tag that we could not find.
def __init__(self, tag_id: int) -> None:
self.tag_id = tag_id
def __repr__(self) -> str:
return f"RegisteredDummy(tag_id={self.tag_id})"
class Mask:
def __init__(self, bounds: Rectangle) -> None:
self.bounds = bounds
self.rectangle: Optional[Image.Image] = None
class PlacedObject:
# An object that occupies the screen at some depth.
def __init__(self, object_id: int, depth: int, rotation_origin: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask]) -> None:
self.__object_id = object_id
self.__depth = depth
self.rotation_origin = rotation_origin
self.transform = transform
self.mult_color = mult_color
self.add_color = add_color
self.blend = blend
self.mask = mask
self.visible: bool = True
@property
def source(self) -> Union[RegisteredClip, RegisteredShape, RegisteredImage, RegisteredDummy]:
raise NotImplementedError("Only implemented in subclass!")
@property
def depth(self) -> int:
return self.__depth
@property
def object_id(self) -> int:
return self.__object_id
def __repr__(self) -> str:
return f"PlacedObject(object_id={self.object_id}, depth={self.depth})"
class PlacedShape(PlacedObject):
# A shape that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag
# referencing an AP2ShapeTag.
def __init__(
self,
object_id: int,
depth: int,
rotation_origin: Point,
transform: Matrix,
mult_color: Color,
add_color: Color,
blend: int,
mask: Optional[Mask],
source: RegisteredShape,
) -> None:
super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask)
self.__source = source
@property
def source(self) -> RegisteredShape:
return self.__source
def __repr__(self) -> str:
return f"PlacedShape(object_id={self.object_id}, depth={self.depth}, source={self.source})"
class PlacedClip(PlacedObject):
# A movieclip that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag
# referencing an AP2DefineSpriteTag. Essentially an embedded movie clip.
def __init__(
self,
object_id: int,
depth: int,
rotation_origin: Point,
transform: Matrix,
mult_color: Color,
add_color: Color,
blend: int,
mask: Optional[Mask],
source: RegisteredClip,
) -> None:
super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask)
self.placed_objects: List[PlacedObject] = []
self.frame: int = 0
self.unplayed_tags: List[int] = [i for i in range(len(source.tags))]
self.__source = source
# Dynamic properties that are adjustable by SWF bytecode.
self.playing: bool = True
self.requested_frame: Optional[int] = None
self.visible_frame: int = -1
@property
def source(self) -> RegisteredClip:
return self.__source
def __check_visible(self) -> None:
if self.visible_frame >= 0 and self.frame >= self.visible_frame:
self.visible = True
self.visible_frame = -1
def advance(self) -> None:
if self.frame < len(self.source.frames):
self.frame += 1
self.__check_visible()
def rewind(self) -> None:
self.frame = 0
self.unplayed_tags = [i for i in range(len(self.__source.tags))]
self.placed_objects = []
self.__check_visible()
@property
def finished(self) -> bool:
return self.frame == len(self.source.frames)
def __repr__(self) -> str:
return (
f"PlacedClip(object_id={self.object_id}, depth={self.depth}, source={self.source}, frame={self.frame}, " +
f"requested_frame={self.requested_frame}, total_frames={len(self.source.frames)}, playing={self.playing}, " +
f"finished={self.finished})"
)
def __resolve_frame(self, frame: Any) -> Optional[int]:
if isinstance(frame, int):
return frame
if isinstance(frame, str):
if frame in self.__source.labels:
return self.__source.labels[frame]
return None
# The following are attributes and functions necessary to support some simple bytecode.
def gotoAndStop(self, frame: Any) -> None:
actual_frame = self.__resolve_frame(frame)
if actual_frame is None:
print(f"WARNING: Unrecognized frame {frame} to gotoAndStop function!")
return
if actual_frame <= 0 or actual_frame > len(self.source.frames):
return
self.requested_frame = actual_frame
self.playing = False
def gotoAndPlay(self, frame: Any) -> None:
actual_frame = self.__resolve_frame(frame)
if actual_frame is None:
print(f"WARNING: Non-integer frame {frame} to gotoAndPlay function!")
return
if actual_frame <= 0 or actual_frame > len(self.source.frames):
return
self.requested_frame = actual_frame
self.playing = True
def stop(self) -> None:
self.playing = False
def play(self) -> None:
self.playing = True
def setInvisibleUntil(self, frame: Any) -> None:
actual_frame = self.__resolve_frame(frame)
if actual_frame is None:
print(f"WARNING: Non-integer frame {frame} to setInvisibleUntil function!")
return
self.visible = False
if actual_frame <= 0 or actual_frame > len(self.source.frames):
return
self.visible_frame = actual_frame
@property
def frameOffset(self) -> int:
return self.requested_frame or self.frame
@frameOffset.setter
def frameOffset(self, val: Any) -> None:
actual_frame = self.__resolve_frame(val)
if actual_frame is None:
print(f"WARNING: Non-integer frameOffset {val} to frameOffset attribute!")
return
if actual_frame < 0 or actual_frame >= len(self.source.frames):
return
self.requested_frame = actual_frame + 1
class PlacedImage(PlacedObject):
# An image that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag
# referencing an AP2ImageTag.
def __init__(
self,
object_id: int,
depth: int,
rotation_origin: Point,
transform: Matrix,
mult_color: Color,
add_color: Color,
blend: int,
mask: Optional[Mask],
source: RegisteredImage,
) -> None:
super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask)
self.__source = source
@property
def source(self) -> RegisteredImage:
return self.__source
def __repr__(self) -> str:
return f"PlacedImage(object_id={self.object_id}, depth={self.depth}, source={self.source})"
class PlacedDummy(PlacedObject):
# A reference to an object we can't find because we're missing the import.
def __init__(
self,
object_id: int,
depth: int,
rotation_origin: Point,
transform: Matrix,
mult_color: Color,
add_color: Color,
blend: int,
mask: Optional[Mask],
source: RegisteredDummy,
) -> None:
super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask)
self.__source = source
@property
def source(self) -> RegisteredDummy:
return self.__source
class Global:
def __init__(self, root: PlacedClip, clip: PlacedClip) -> None:
self.root = root
self.clip = clip
def getInstanceAtDepth(self, depth: Any) -> Any:
if not isinstance(depth, int):
return UNDEFINED
# For some reason, it looks like internally the depth of all objects is
# stored added to -0x4000, so let's reverse that.
depth = depth + 0x4000
for obj in self.clip.placed_objects:
if obj.depth == depth:
return obj
print(f"WARNING: Could not find object at depth {depth}!")
return UNDEFINED
def __find_parent(self, parent: PlacedClip, child: PlacedClip) -> Optional[PlacedClip]:
for obj in parent.placed_objects:
if obj is child:
# This is us, so the parent is our parent.
return parent
if isinstance(obj, PlacedClip):
maybe_parent = self.__find_parent(obj, child)
if maybe_parent is not None:
return maybe_parent
return None
def find_parent(self, child: PlacedClip) -> Optional[PlacedClip]:
return self.__find_parent(self.root, child)
class AEPLib:
def aep_set_rect_mask(self, thisptr: Any, left: Any, right: Any, top: Any, bottom: Any) -> None:
if not isinstance(left, (int, float)) or not isinstance(right, (int, float)) or not isinstance(top, (int, float)) or not isinstance(bottom, (int, float)):
print(f"WARNING: Ignoring aeplib.aep_set_rect_mask call with invalid parameters {left}, {right}, {top}, {bottom}!")
return
if isinstance(thisptr, PlacedObject):
thisptr.mask = Mask(
Rectangle(
left=float(left),
right=float(right),
top=float(top),
bottom=float(bottom),
),
)
else:
print(f"WARNING: Ignoring aeplib.aep_set_rect_mask call with unrecognized target {thisptr}!")
def aep_set_set_frame(self, thisptr: Any, frame: Any) -> None:
# This appears to be some sort of callback that the game or other animations can use to figure out
# what frame of animation is currently happening. Whenever I've seen it, it is with the 'frame' set
# to an integer value that matches the currently rendering frame in the render loop. I think its
# safe to ignore this, but if we ever create animations it might be necessary to add calls to this.
pass
def aep_set_frame_control(self, thisptr: Any, depth: Any, frame: Any) -> None:
if not isinstance(thisptr, PlacedClip):
print(f"WARNING: Ignoring aeplib.aep_set_frame_control with unrecognized current object {thisptr}!")
return
for obj in thisptr.placed_objects:
if obj.depth == depth:
if not isinstance(obj, PlacedClip):
print(f"WARNING: Ignoring aeplib.aep_set_frame_control called on object {obj} at depth {depth}!")
return
obj.setInvisibleUntil(frame)
return
print(f"WARNING: Ignoring aeplib.aep_set_frame_control called on nonexistent object at depth {depth}!")
def gotoAndPlay(self, thisptr: Any, frame: Any) -> Any:
# This appears to be a wrapper to allow calling gotoAndPlay on clips.
try:
meth = getattr(thisptr, 'gotoAndPlay')
# Call it, set the return on the stack.
return meth(frame)
except AttributeError:
# Function does not exist!
print(f"WARNING: Tried to call gotoAndPlay({frame}) on {thisptr} but that method doesn't exist!")
return UNDEFINED
def deepGotoAndPlay(self, thisptr: Any, frame: Any) -> Any:
# I don't know how this differs from regular gotoAndPlay.
try:
meth = getattr(thisptr, 'gotoAndPlay')
# Call it, set the return on the stack.
return meth(frame)
except AttributeError:
# Function does not exist!
print(f"WARNING: Tried to call gotoAndPlay({frame}) on {thisptr} but that method doesn't exist!")
return UNDEFINED
def stop(self, thisptr: Any) -> Any:
# This appears to be a wrapper to allow calling stop on clips.
try:
meth = getattr(thisptr, 'stop')
# Call it, set the return on the stack.
return meth()
except AttributeError:
# Function does not exist!
print(f"WARNING: Tried to call stop() on {thisptr} but that method doesn't exist!")
return UNDEFINED
class ASDLib:
def sound_play(self, sound: Any) -> None:
if not isinstance(sound, str):
print(f"WARNING: Ignoring asdlib.sound_play call with invalid parameters {sound}!")
print(f"WARNING: Requested sound {sound} be played but we don't support sound yet!")
MissingThis = object()
class AFPRenderer(VerboseOutput):
def __init__(self, shapes: Dict[str, Shape] = {}, textures: Dict[str, Image.Image] = {}, swfs: Dict[str, SWF] = {}, single_threaded: bool = False, enable_aa: bool = False) -> None:
super().__init__()
# Options for rendering
self.__single_threaded = single_threaded
self.__enable_aa = enable_aa
# Library of shapes (draw instructions), textures (actual images) and swfs (us and other files for imports).
self.shapes: Dict[str, Shape] = shapes
self.textures: Dict[str, Image.Image] = textures
self.swfs: Dict[str, SWF] = swfs
# Internal render parameters.
self.__registered_objects: Dict[int, Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]] = {}
self.__root: Optional[PlacedClip] = None
# List of imports that we provide stub implementations for.
self.__stubbed_swfs: Set[str] = {
'aeplib.aeplib',
'aeplib.__Packages.aeplib',
}
def add_shape(self, name: str, data: Shape) -> None:
# Register a named shape with the renderer.
if not data.parsed:
data.parse()
self.shapes[name] = data
def add_texture(self, name: str, data: Image.Image) -> None:
# Register a named texture (already loaded PIL image) with the renderer.
self.textures[name] = data.convert("RGBA")
def add_swf(self, name: str, data: SWF) -> None:
# Register a named SWF with the renderer.
if not data.parsed:
data.parse()
self.swfs[name] = data
def render_path(
self,
path: str,
background_color: Optional[Color] = None,
background_image: Optional[Image.Image] = None,
only_depths: Optional[List[int]] = None,
only_frames: Optional[List[int]] = None,
movie_transform: Matrix = Matrix.identity(),
verbose: bool = False,
) -> Generator[Image.Image, None, None]:
# Given a path to a SWF root animation, attempt to render it to a list of frames.
for _name, swf in self.swfs.items():
if swf.exported_name == path:
# This is the SWF we care about.
with self.debugging(verbose):
swf.color = background_color or swf.color
yield from self.__render(swf, only_depths, only_frames, movie_transform, background_image)
return
raise Exception(f'{path} not found in registered SWFs!')
def compute_path_location(
self,
path: str,
) -> Rectangle:
# Given a path to a SWF root animation, find its bounding rectangle.
for _name, swf in self.swfs.items():
if swf.exported_name == path:
# This is the SWF we care about.
return swf.location
raise Exception(f'{path} not found in registered SWFs!')
def compute_path_frames(
self,
path: str,
) -> int:
# Given a path to a SWF root animation, figure out how many frames are
# in that root path with no regard to bytecode 'stop()' commands.
for _name, swf in self.swfs.items():
if swf.exported_name == path:
# This is the SWF we care about.
return len(swf.frames)
raise Exception(f'{path} not found in registered SWFs!')
def compute_path_frame_duration(
self,
path: str,
) -> int:
# Given a path to a SWF root animation, figure out how many milliseconds are
# occupied by each frame.
for _name, swf in self.swfs.items():
if swf.exported_name == path:
# This is the SWF we care about.
spf = 1.0 / swf.fps
return int(spf * 1000.0)
raise Exception(f'{path} not found in registered SWFs!')
def compute_path_size(
self,
path: str,
) -> Rectangle:
# Given a path to a SWF root animation, figure out what the dimensions
# of the SWF are.
for _name, swf in self.swfs.items():
if swf.exported_name == path:
return swf.location
raise Exception(f'{path} not found in registered SWFs!')
def list_paths(self, verbose: bool = False) -> Generator[str, None, None]:
# Given the loaded animations, return a list of possible paths to render.
for _name, swf in self.swfs.items():
yield swf.exported_name
def __execute_bytecode(self, bytecode: ByteCode, clip: PlacedClip, thisptr: Optional[Any] = MissingThis, prefix: str="") -> None:
if self.__root is None:
raise Exception("Logic error, executing bytecode outside of a rendering movie clip!")
thisobj = clip if (thisptr is MissingThis) else thisptr
globalobj = Global(self.__root, clip)
location: int = 0
stack: List[Any] = []
variables: Dict[str, Any] = {
'aeplib': AEPLib(),
'asdlib': ASDLib(),
}
registers: List[Any] = [UNDEFINED] * 256
self.vprint(f"{prefix}Bytecode engine starting.")
while location < len(bytecode.actions):
action = bytecode.actions[location]
if action.opcode == AP2Action.END:
# End the execution.
self.vprint(f"{prefix} Ending bytecode execution.")
break
elif action.opcode == AP2Action.GET_VARIABLE:
varname = stack.pop()
# Look up the variable, put it on the stack.
if varname in variables:
stack.append(variables[varname])
else:
stack.append(UNDEFINED)
elif action.opcode == AP2Action.SET_MEMBER:
# Grab what we're about to do.
set_value = stack.pop()
attribute = stack.pop()
obj = stack.pop()
if not hasattr(obj, attribute):
print(f"WARNING: Tried to set attribute {attribute} on {obj} but that attribute doesn't exist!")
else:
self.vprint(f"{prefix} Setting attribute {attribute} on {obj} to {set_value}")
setattr(obj, attribute, set_value)
elif action.opcode == AP2Action.CALL_METHOD:
# Grab the method name.
methname = stack.pop()
# Grab the object to perform the call on.
obj = stack.pop()
# Grab the parameters to pass to the function.
num_params = stack.pop()
if not isinstance(num_params, int):
raise Exception("Logic error, cannot get number of parameters to method call!")
params = []
for _ in range(num_params):
params.append(stack.pop())
# Look up the python function we're calling.
try:
self.vprint(f"{prefix} Calling method {methname}({', '.join(repr(s) for s in params)}) on {obj}")
meth = getattr(obj, methname)
# Call it, set the return on the stack.
stack.append(meth(*params))
except AttributeError:
# Function does not exist!
print(f"WARNING: Tried to call {methname}({', '.join(repr(s) for s in params)}) on {obj} but that method doesn't exist!")
stack.append(UNDEFINED)
elif action.opcode == AP2Action.CALL_FUNCTION:
# Grab the method name.
funcname = stack.pop()
# Grab the parameters to pass to the function.
num_params = stack.pop()
if not isinstance(num_params, int):
raise Exception("Logic error, cannot get number of parameters to function call!")
params = []
for _ in range(num_params):
params.append(stack.pop())
# Look up the python function we're calling.
try:
self.vprint(f"{prefix} Calling global function {funcname}({', '.join(repr(s) for s in params)})")
func = getattr(globalobj, funcname)
# Call it, set the return on the stack.
stack.append(func(*params))
except AttributeError:
# Function does not exist!
print(f"WARNING: Tried to call {funcname}({', '.join(repr(s) for s in params)}) on {globalobj} but that function doesn't exist!")
stack.append(UNDEFINED)
elif isinstance(action, PushAction):
for obj in action.objects:
if isinstance(obj, Register):
stack.append(registers[obj.no])
elif isinstance(obj, StringConstant):
if obj.alias:
stack.append(obj.alias)
else:
stack.append(StringConstant.property_to_name(obj.const))
elif obj is NULL:
stack.append(None)
elif obj is THIS:
stack.append(thisobj)
elif obj is GLOBAL:
stack.append(globalobj)
elif obj is ROOT:
stack.append(self.__root)
elif obj is CLIP:
# I am not sure this is correct? Maybe it works out
# in circumstances where "THIS" is pointed at something
# else, such as defined function calls maybe?
stack.append(clip)
elif obj is PARENT:
# Find the parent of this clip.
stack.append(globalobj.find_parent(clip) or UNDEFINED)
else:
stack.append(obj)
elif isinstance(action, StoreRegisterAction):
set_value = stack.pop()
if action.preserve_stack:
stack.append(set_value)
for reg in action.registers:
registers[reg.no] = set_value
elif action.opcode == AP2Action.POP:
stack.pop()
else:
print(f"WARNING: Unhandled opcode {action} with stack {stack}")
# Next opcode!
location += 1
self.vprint(f"{prefix}Bytecode engine finished.")
def __place(self, tag: Tag, operating_clip: PlacedClip, prefix: str = "") -> Tuple[Optional[PlacedClip], bool]:
# "Place" a tag on the screen. Most of the time, this means performing the action of the tag,
# such as defining a shape (registering it with our shape list) or adding/removing an object.
if isinstance(tag, AP2ShapeTag):
self.vprint(f"{prefix} Loading {tag.reference} into object slot {tag.id}")
if tag.reference not in self.shapes:
raise Exception(f"Cannot find shape reference {tag.reference}!")
self.__registered_objects[tag.id] = RegisteredShape(
tag.id,
self.shapes[tag.reference].vertex_points,
self.shapes[tag.reference].tex_points,
self.shapes[tag.reference].tex_colors,
self.shapes[tag.reference].draw_params,
)
# Didn't place a new clip, didn't change anything.
return None, False
elif isinstance(tag, AP2ImageTag):
self.vprint(f"{prefix} Loading {tag.reference} into object slot {tag.id}")
if tag.reference not in self.textures:
raise Exception(f"Cannot find texture reference {tag.reference}!")
self.__registered_objects[tag.id] = RegisteredImage(
tag.id,
tag.reference,
)
# Didn't place a new clip, didn't change anything.
return None, False
elif isinstance(tag, AP2DefineSpriteTag):
self.vprint(f"{prefix} Loading Sprite into object slot {tag.id}")
# Register a new clip that we might reference to execute.
self.__registered_objects[tag.id] = RegisteredClip(tag.id, tag.frames, tag.tags, tag.labels)
# Didn't place a new clip, didn't change anything.
return None, False
elif isinstance(tag, AP2PlaceObjectTag):
if tag.unrecognized_options:
if tag.source_tag_id is not None:
print(f"WARNING: Place object tag referencing {tag.source_tag_id} includes unparsed options and might not display properly!")
else:
print(f"WARNING: Place object tag on depth {tag.depth} includes unparsed options and might not display properly!")
if tag.update:
for i in range(len(operating_clip.placed_objects) - 1, -1, -1):
obj = operating_clip.placed_objects[i]
if obj.object_id == tag.object_id and obj.depth == tag.depth:
new_mult_color = tag.mult_color or obj.mult_color
new_add_color = tag.add_color or obj.add_color
new_transform = tag.transform or obj.transform
new_rotation_origin = tag.rotation_origin or obj.rotation_origin
new_blend = tag.blend or obj.blend
if tag.source_tag_id is not None and tag.source_tag_id != obj.source.tag_id:
# This completely updates the pointed-at object.
self.vprint(f"{prefix} Replacing Object source {obj.source.tag_id} with {tag.source_tag_id} on object with Object ID {tag.object_id} onto Depth {tag.depth}")
newobj = self.__registered_objects[tag.source_tag_id]
if isinstance(newobj, RegisteredShape):
operating_clip.placed_objects[i] = PlacedShape(
obj.object_id,
obj.depth,
new_rotation_origin,
new_transform,
new_mult_color,
new_add_color,
new_blend,
obj.mask,
newobj,
)
# Didn't place a new clip, changed the parent clip.
return None, True
elif isinstance(newobj, RegisteredImage):
operating_clip.placed_objects[i] = PlacedImage(
obj.object_id,
obj.depth,
new_rotation_origin,
new_transform,
new_mult_color,
new_add_color,
new_blend,
obj.mask,
newobj,
)
# Didn't place a new clip, changed the parent clip.
return None, True
elif isinstance(newobj, RegisteredClip):
new_clip = PlacedClip(
tag.object_id,
tag.depth,
new_rotation_origin,
new_transform,
new_mult_color,
new_add_color,
new_blend,
obj.mask,
newobj,
)
operating_clip.placed_objects[i] = new_clip
# Placed a new clip, changed the parent.
return new_clip, True
elif isinstance(newobj, RegisteredDummy):
operating_clip.placed_objects[i] = PlacedDummy(
obj.object_id,
obj.depth,
new_rotation_origin,
new_transform,
new_mult_color,
new_add_color,
new_blend,
obj.mask,
newobj,
)
# Didn't place a new clip, changed the parent clip.
return None, True
else:
raise Exception(f"Unrecognized object with Tag ID {tag.source_tag_id}!")
else:
# As far as I can tell, pretty much only color and matrix stuff can be updated.
self.vprint(f"{prefix} Updating Object ID {tag.object_id} on Depth {tag.depth}")
obj.mult_color = new_mult_color
obj.add_color = new_add_color
obj.transform = new_transform
obj.rotation_origin = new_rotation_origin
obj.blend = new_blend
return None, True
# Didn't place a new clip, did change something.
print(f"WARNING: Couldn't find tag {tag.object_id} on depth {tag.depth} to update!")
return None, False
else:
if tag.source_tag_id is None:
raise Exception("Cannot place a tag with no source ID and no update flags!")
if tag.source_tag_id in self.__registered_objects:
self.vprint(f"{prefix} Placing Object {tag.source_tag_id} with Object ID {tag.object_id} onto Depth {tag.depth}")
newobj = self.__registered_objects[tag.source_tag_id]
if isinstance(newobj, RegisteredShape):
operating_clip.placed_objects.append(
PlacedShape(
tag.object_id,
tag.depth,
tag.rotation_origin or Point.identity(),
tag.transform or Matrix.identity(),
tag.mult_color or Color(1.0, 1.0, 1.0, 1.0),
tag.add_color or Color(0.0, 0.0, 0.0, 0.0),
tag.blend or 0,
None,
newobj,
)
)
# Didn't place a new clip, changed the parent clip.
return None, True
elif isinstance(newobj, RegisteredImage):
operating_clip.placed_objects.append(
PlacedImage(
tag.object_id,
tag.depth,
tag.rotation_origin or Point.identity(),
tag.transform or Matrix.identity(),
tag.mult_color or Color(1.0, 1.0, 1.0, 1.0),
tag.add_color or Color(0.0, 0.0, 0.0, 0.0),
tag.blend or 0,
None,
newobj,
)
)
# Didn't place a new clip, changed the parent clip.
return None, True
elif isinstance(newobj, RegisteredClip):
placed_clip = PlacedClip(
tag.object_id,
tag.depth,
tag.rotation_origin or Point.identity(),
tag.transform or Matrix.identity(),
tag.mult_color or Color(1.0, 1.0, 1.0, 1.0),
tag.add_color or Color(0.0, 0.0, 0.0, 0.0),
tag.blend or 0,
None,
newobj,
)
operating_clip.placed_objects.append(placed_clip)
for flags, code in tag.triggers.items():
if flags & AP2Trigger.ON_LOAD:
for bytecode in code:
self.__execute_bytecode(bytecode, placed_clip, prefix=prefix + " ")
else:
print("WARNING: Unhandled PLACE_OBJECT trigger with flags {flags}!")
# Placed a new clip, changed the parent.
return placed_clip, True
elif isinstance(newobj, RegisteredDummy):
operating_clip.placed_objects.append(
PlacedDummy(
tag.object_id,
tag.depth,
tag.rotation_origin or Point.identity(),
tag.transform or Matrix.identity(),
tag.mult_color or Color(1.0, 1.0, 1.0, 1.0),
tag.add_color or Color(0.0, 0.0, 0.0, 0.0),
tag.blend or 0,
None,
newobj,
)
)
# Didn't place a new clip, changed the parent clip.
return None, True
else:
raise Exception(f"Unrecognized object with Tag ID {tag.source_tag_id}!")
raise Exception(f"Cannot find a shape or sprite with Tag ID {tag.source_tag_id}!")
elif isinstance(tag, AP2RemoveObjectTag):
self.vprint(f"{prefix} Removing Object ID {tag.object_id} from Depth {tag.depth}")
if tag.object_id != 0:
# Remove the identified object by object ID and depth.
# Remember removed objects so we can stop any clips.
removed_objects = [
obj for obj in operating_clip.placed_objects
if obj.object_id == tag.object_id and obj.depth == tag.depth
]
# Get rid of the objects that we're removing from the master list.
operating_clip.placed_objects = [
obj for obj in operating_clip.placed_objects
if not(obj.object_id == tag.object_id and obj.depth == tag.depth)
]
else:
# Remove the last placed object at this depth. The placed objects list isn't
# ordered so much as apppending to the list means the last placed object at a
# depth comes last.
removed_objects = []
for i in range(len(operating_clip.placed_objects)):
real_index = len(operating_clip.placed_objects) - (i + 1)
if operating_clip.placed_objects[real_index].depth == tag.depth:
removed_objects = operating_clip.placed_objects[real_index:(real_index + 1)]
operating_clip.placed_objects = operating_clip.placed_objects[:real_index] + operating_clip.placed_objects[(real_index + 1):]
break
if not removed_objects:
print(f"WARNING: Couldn't find object to remove by ID {tag.object_id} and depth {tag.depth}!")
# TODO: Handle ON_UNLOAD triggers for this object. I don't think I've ever seen one
# on any object so this might be a pedantic request.
# Didn't place a new clip, changed parent clip.
return None, True
elif isinstance(tag, AP2DoActionTag):
self.vprint(f"{prefix} Execution action tag.")
self.__execute_bytecode(tag.bytecode, operating_clip, prefix=prefix + " ")
# Didn't place a new clip.
return None, False
elif isinstance(tag, AP2DefineFontTag):
print("WARNING: Unhandled DEFINE_FONT tag!")
# Didn't place a new clip.
return None, False
elif isinstance(tag, AP2DefineEditTextTag):
print("WARNING: Unhandled DEFINE_EDIT_TEXT tag!")
# Didn't place a new clip.
return None, False
elif isinstance(tag, AP2PlaceCameraTag):
print("WARNING: Unhandled PLACE_CAMERA tag!")
# Didn't place a new clip.
return None, False
else:
raise Exception(f"Failed to process tag: {tag}")
def __apply_mask(
self,
parent_mask: Image.Image,
transform: Matrix,
mask: Mask,
) -> Image.Image:
if mask.rectangle is None:
# Calculate the new mask rectangle.
mask.rectangle = Image.new('RGBA', (int(mask.bounds.width), int(mask.bounds.height)), (255, 0, 0, 255))
# Offset it by its top/left.
transform = transform.translate(Point(mask.bounds.left, mask.bounds.top))
# Draw the mask onto a new image.
calculated_mask = affine_composite(
Image.new('RGBA', (parent_mask.width, parent_mask.height), (0, 0, 0, 0)),
Color(0.0, 0.0, 0.0, 0.0),
Color(1.0, 1.0, 1.0, 1.0),
transform,
None,
257,
mask.rectangle,
single_threaded=self.__single_threaded,
enable_aa=False,
)
# Composite it onto the current mask.
return affine_composite(
parent_mask.copy(),
Color(0.0, 0.0, 0.0, 0.0),
Color(1.0, 1.0, 1.0, 1.0),
Matrix.identity(),
None,
256,
calculated_mask,
single_threaded=self.__single_threaded,
enable_aa=False,
)
def __render_object(
self,
img: Image.Image,
renderable: PlacedObject,
parent_transform: Matrix,
parent_mask: Image.Image,
parent_mult_color: Color,
parent_add_color: Color,
parent_blend: int,
only_depths: Optional[List[int]] = None,
prefix: str="",
) -> Image.Image:
if not renderable.visible:
self.vprint(f"{prefix} Ignoring invisible placed object ID {renderable.object_id} from sprite {renderable.source.tag_id} on Depth {renderable.depth}")
return img
self.vprint(f"{prefix} Rendering placed object ID {renderable.object_id} from sprite {renderable.source.tag_id} onto Depth {renderable.depth}")
# Compute the affine transformation matrix for this object.
transform = renderable.transform.multiply(parent_transform).translate(Point.identity().subtract(renderable.rotation_origin))
# Calculate blending and blend color if it is present.
mult_color = (renderable.mult_color or Color(1.0, 1.0, 1.0, 1.0)).multiply(parent_mult_color)
add_color = (renderable.add_color or Color(0.0, 0.0, 0.0, 0.0)).multiply(parent_mult_color).add(parent_add_color)
blend = renderable.blend or 0
if parent_blend not in {0, 1, 2} and blend in {0, 1, 2}:
blend = parent_blend
if renderable.mask:
mask = self.__apply_mask(parent_mask, transform, renderable.mask)
else:
mask = parent_mask
# Render individual shapes if this is a sprite.
if isinstance(renderable, PlacedClip):
new_only_depths: Optional[List[int]] = None
if only_depths is not None:
if renderable.depth not in only_depths:
if renderable.depth != -1:
# Not on the correct depth plane.
return img
new_only_depths = only_depths
# This is a sprite placement reference. Make sure that we render lower depths
# first, but preserved placed order as well.
depths = set(obj.depth for obj in renderable.placed_objects)
for depth in sorted(depths):
for obj in renderable.placed_objects:
if obj.depth != depth:
continue
img = self.__render_object(img, obj, transform, mask, mult_color, add_color, blend, only_depths=new_only_depths, prefix=prefix + " ")
elif isinstance(renderable, PlacedShape):
if only_depths is not None and renderable.depth not in only_depths:
# Not on the correct depth plane.
return img
# This is a shape draw reference.
shape = renderable.source
# Now, render out shapes.
for params in shape.draw_params:
if not (params.flags & 0x1):
# Not instantiable, don't render.
return img
if params.flags & 0x4:
# TODO: Need to support blending and UV coordinate colors here.
print("WARNING: Unhandled UV coordinate color!")
texture = None
if params.flags & 0x2:
# We need to look up the texture for this.
if params.region not in self.textures:
raise Exception(f"Cannot find texture reference {params.region}!")
texture = self.textures[params.region]
if params.flags & 0x8:
# TODO: This texture gets further blended somehow? Not sure this is ever used.
print(f"WARNING: Unhandled texture blend color {params.blend}!")
elif params.flags & 0x8:
if shape.rectangle is None:
# This is a raw rectangle. Its possible that the number of vertex points is
# not 4, or that the four points in the vertex_points aren't the four corners
# of a rectangle, but let's assume that doesn't happen for now.
if len(shape.vertex_points) != 4:
print("WARNING: Unsupported non-rectangle shape!")
if params.blend is None:
raise Exception("Logic error, rectangle without a blend color!")
x_points = set(p.x for p in shape.vertex_points)
y_points = set(p.y for p in shape.vertex_points)
left = min(x_points)
right = max(x_points)
top = min(y_points)
bottom = max(y_points)
# Make sure that the four corners are aligned.
bad = False
for point in x_points:
if point not in {left, right}:
bad = True
break
for point in y_points:
if point not in {top, bottom}:
bad = True
break
if bad:
print("WARNING: Unsupported non-rectangle shape!")
shape.rectangle = Image.new('RGBA', (int(right - left), int(bottom - top)), (params.blend.as_tuple()))
texture = shape.rectangle
if texture is not None:
img = affine_composite(img, add_color, mult_color, transform, mask, blend, texture, single_threaded=self.__single_threaded, enable_aa=self.__enable_aa)
elif isinstance(renderable, PlacedImage):
if only_depths is not None and renderable.depth not in only_depths:
# Not on the correct depth plane.
return img
# This is a shape draw reference.
texture = self.textures[renderable.source.reference]
img = affine_composite(img, add_color, mult_color, transform, mask, blend, texture, single_threaded=self.__single_threaded, enable_aa=self.__enable_aa)
elif isinstance(renderable, PlacedDummy):
# Nothing to do!
pass
else:
raise Exception(f"Unknown placed object type to render {renderable}!")
return img
def __is_dirty(self, clip: PlacedClip) -> bool:
# If we are dirty ourselves, then the clip is definitely dirty.
if clip.requested_frame is not None:
return True
# If one of our children is dirty, then we are dirty.
for child in clip.placed_objects:
if isinstance(child, PlacedClip):
if self.__is_dirty(child):
return True
# None of our children (or their children, etc...) or ourselves is dirty.
return False
def __process_tags(self, clip: PlacedClip, only_dirty: bool, prefix: str = " ") -> bool:
self.vprint(f"{prefix}Handling {'dirty updates on ' if only_dirty else ''}placed clip {clip.object_id} at depth {clip.depth}")
# Track whether anything in ourselves or our children changes during this processing.
changed = False
# Make sure to set the requested frame if it isn't set by an external force.
if clip.requested_frame is None:
if not clip.playing or clip.finished or only_dirty:
# We aren't playing this clip because its either paused or finished,
# or it isn't dirty and we're doing dirty updates only. So, we don't
# need to advance to any frame.
clip.requested_frame = clip.frame
else:
# We need to do as many things as we need to get to the next frame.
clip.requested_frame = clip.frame + 1
while True:
# First, see if we need to rewind the clip if we were requested to go backwards
# during some bytecode update in this loop.
if clip.frame > clip.requested_frame:
# Rewind this clip to the beginning so we can replay until the requested frame.
if clip is self.__root:
print("WARNING: Root clip was rewound, its possible this animation plays forever!")
clip.rewind()
self.vprint(f"{prefix} Processing frame {clip.frame} on our way to frame {clip.requested_frame}")
# Clips that are part of our own placed objects which we should handle.
child_clips = [c for c in clip.placed_objects if isinstance(c, PlacedClip)]
# Execute each tag in the frame if we need to move forward to a new frame.
if clip.frame != clip.requested_frame:
frame = clip.source.frames[clip.frame]
orphans: List[Tag] = []
played_tags: Set[int] = set()
# See if we have any orphans that need to be placed before this frame will work.
for unplayed_tag in clip.unplayed_tags:
if unplayed_tag < frame.start_tag_offset:
self.vprint(f"{prefix} Including orphaned tag {unplayed_tag} in frame evaluation")
played_tags.add(unplayed_tag)
orphans.append(clip.source.tags[unplayed_tag])
for tagno in range(frame.start_tag_offset, frame.start_tag_offset + frame.num_tags):
played_tags.add(tagno)
# Check these off our future todo list.
clip.unplayed_tags = [t for t in clip.unplayed_tags if t not in played_tags]
# Grab the normal list of tags, add to the orphans.
tags = orphans + clip.source.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)]
for tagno, tag in enumerate(tags):
# Perform the action of this tag.
self.vprint(f"{prefix} Sprite Tag ID: {clip.source.tag_id}, Current Tag: {frame.start_tag_offset + tagno}, Num Tags: {frame.num_tags}")
new_clip, clip_changed = self.__place(tag, clip, prefix=prefix)
changed = changed or clip_changed
# If we create a new movie clip, process it as well for this frame.
if new_clip:
# These are never dirty-only updates as they're fresh-placed.
changed = self.__process_tags(new_clip, False, prefix=prefix + " ") or changed
# Now, advance the frame for this clip since we processed the frame.
clip.advance()
# Now, handle each of the existing clips.
for child in child_clips:
changed = self.__process_tags(child, only_dirty, prefix=prefix + " ") or changed
# See if we're done with this clip.
if clip.frame == clip.requested_frame:
clip.requested_frame = None
break
self.vprint(f"{prefix}Finished handling {'dirty updates on ' if only_dirty else ''}placed clip {clip.object_id} at depth {clip.depth}")
# Return if anything was modified.
return changed
def __handle_imports(self, swf: SWF) -> Dict[int, Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]]:
external_objects: Dict[int, Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]] = {}
# Go through, recursively resolve imports for all SWF files.
for tag_id, imp in swf.imported_tags.items():
for _name, other in self.swfs.items():
if other.exported_name == imp.swf:
# This SWF should have the tag reference.
if imp.tag not in other.exported_tags:
print(f"WARNING: {swf.exported_name} imports {imp} but that import is not in {other.exported_name}!")
external_objects[tag_id] = RegisteredDummy(tag_id)
break
else:
external_objects[tag_id] = self.__find_import(other, other.exported_tags[imp.tag])
break
else:
# Only display a warning if we don't have our own stub implementation of this SWF.
if repr(imp) not in self.__stubbed_swfs:
print(f"WARNING: {swf.exported_name} imports {imp} but that SWF is not in our library!")
external_objects[tag_id] = RegisteredDummy(tag_id)
# Fix up tag IDs to point at our local definition of them.
for tid in external_objects:
external_objects[tid].tag_id = tid
# Return our newly populated registered object table containing all imports!
return external_objects
def __find_import(self, swf: SWF, tag_id: int) -> Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]:
if tag_id in swf.imported_tags:
external_objects = self.__handle_imports(swf)
if tag_id not in external_objects:
raise Exception(f"Logic error, tag ID {tag_id} is an export for {swf.exported_name} but we didn't populate it!")
return external_objects[tag_id]
# We need to do a basic placement to find the registered object so we can return it.
root_clip = RegisteredClip(
None,
swf.frames,
swf.tags,
swf.labels,
)
tag = self.__find_tag(root_clip, tag_id)
if tag is None:
print(f"WARNING: {swf.exported_name} exports {swf.imported_tags[tag_id]} but does not manifest an object!")
return RegisteredDummy(tag_id)
return tag
def __find_tag(self, clip: RegisteredClip, tag_id: int) -> Optional[Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]]:
# Fake-execute this clip to find the tag we need to manifest.
for frame in clip.frames:
tags = clip.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)]
for tag in tags:
# Attempt to place any tags.
if isinstance(tag, AP2ShapeTag):
if tag.id == tag_id:
# We need to be able to see this shape to place it.
if tag.reference not in self.shapes:
raise Exception(f"Cannot find shape reference {tag.reference}!")
# This matched, so this is the import.
return RegisteredShape(
tag.id,
self.shapes[tag.reference].vertex_points,
self.shapes[tag.reference].tex_points,
self.shapes[tag.reference].tex_colors,
self.shapes[tag.reference].draw_params,
)
elif isinstance(tag, AP2ImageTag):
if tag.id == tag_id:
# We need to be able to see this shape to place it.
if tag.reference not in self.textures:
raise Exception(f"Cannot find texture reference {tag.reference}!")
# This matched, so this is the import.
return RegisteredImage(
tag.id,
tag.reference,
)
elif isinstance(tag, AP2DefineSpriteTag):
new_clip = RegisteredClip(tag.id, tag.frames, tag.tags, tag.labels)
if tag.id == tag_id:
# This matched, so it is the clip that we want to export.
return new_clip
# Recursively look in this as well.
maybe_tag = self.__find_tag(new_clip, tag_id)
if maybe_tag is not None:
return maybe_tag
# We didn't find the tag we were after.
return None
def __render(
self,
swf: SWF,
only_depths: Optional[List[int]],
only_frames: Optional[List[int]],
movie_transform: Matrix,
background_image: Optional[Image.Image],
) -> Generator[Image.Image, None, None]:
# First, let's attempt to resolve imports.
self.__registered_objects = self.__handle_imports(swf)
# Initialize overall frame advancement stuff.
last_rendered_frame: Optional[Image.Image] = None
frameno: int = 0
# Calculate actual size based on given movie transform.
resized_width, resized_height, _ = movie_transform.multiply_point(Point(swf.location.width, swf.location.height)).as_tuple()
# TODO: If the location top/left is nonzero, we need move the root transform
# so that the correct viewport is rendered.
# Create a root clip for the movie to play.
root_clip = PlacedClip(
-1,
-1,
Point.identity(),
Matrix.identity(),
Color(1.0, 1.0, 1.0, 1.0),
Color(0.0, 0.0, 0.0, 0.0),
0,
None,
RegisteredClip(
None,
swf.frames,
swf.tags,
swf.labels,
),
)
self.__root = root_clip
# If we have a background image, add it to the root clip.
if background_image:
# Stretch the image to make sure it fits the entire frame.
imgwidth = float(background_image.width)
imgheight = float(background_image.height)
background_matrix = Matrix.affine(
a=swf.location.width / imgwidth,
b=0,
c=0,
d=swf.location.height / imgheight,
tx=0,
ty=0,
)
# Register the background image with the texture library.
name = f"{swf.exported_name}_inserted_background"
self.textures[name] = background_image.convert("RGBA")
# Place an instance of this background on the root clip.
root_clip.placed_objects.append(
PlacedShape(
-1,
-1,
Point.identity(),
background_matrix,
Color(1.0, 1.0, 1.0, 1.0),
Color(0.0, 0.0, 0.0, 0.0),
0,
None,
RegisteredShape(
-1,
# The coordinates of the rectangle of the shape in screen space.
[
Point(0, 0),
Point(imgwidth, 0),
Point(imgwidth, imgheight),
Point(0, imgheight),
],
# The coordinates of the original texture in UV space (we don't use this).
[
Point(0.0, 0.0),
Point(1.0, 0.0),
Point(1.0, 1.0),
Point(0.0, 1.0),
],
# No texture colors.
[],
[
DrawParams(
# Instantiable, includes texture.
0x3,
# The texture this should use for drawing.
name,
# The coordinates of the triangles that get drawn (we don't use this).
[0, 1, 2, 2, 1, 3],
# The blend color.
None,
),
],
),
),
)
# Create the root mask for where to draw the root clip.
movie_mask = Image.new("RGBA", (resized_width, resized_height), color=(255, 0, 0, 255))
# These could possibly be overwritten from an external source of we wanted.
actual_mult_color = Color(1.0, 1.0, 1.0, 1.0)
actual_add_color = Color(0.0, 0.0, 0.0, 0.0)
actual_blend = 0
max_frame: Optional[int] = None
if only_frames:
max_frame = max(only_frames)
# Now play the frames of the root clip.
try:
while root_clip.playing and not root_clip.finished:
# Create a new image to render into.
self.vprint(f"Rendering frame {frameno + 1}/{len(root_clip.source.frames)}")
# Go through all registered clips, place all needed tags.
changed = self.__process_tags(root_clip, False)
while self.__is_dirty(root_clip):
changed = self.__process_tags(root_clip, True) or changed
# If we're only rendering some frames, don't bother to do the draw operations
# if we aren't going to return the frame.
if only_frames and (frameno + 1) not in only_frames:
self.vprint(f"Skipped rendering frame {frameno + 1}/{len(root_clip.source.frames)}")
last_rendered_frame = None
frameno += 1
continue
if changed or last_rendered_frame is None:
# Now, render out the placed objects.
color = swf.color or Color(0.0, 0.0, 0.0, 0.0)
curimage = Image.new("RGBA", (resized_width, resized_height), color=color.as_tuple())
curimage = self.__render_object(curimage, root_clip, movie_transform, movie_mask, actual_mult_color, actual_add_color, actual_blend, only_depths=only_depths)
else:
# Nothing changed, make a copy of the previous render.
self.vprint(" Using previous frame render")
curimage = last_rendered_frame.copy()
# Return that frame, advance our bookkeeping.
self.vprint(f"Finished rendering frame {frameno + 1}/{len(root_clip.source.frames)}")
last_rendered_frame = curimage
frameno += 1
yield curimage
# See if we should bail because we passed the last requested frame.
if max_frame is not None and frameno == max_frame:
break
except KeyboardInterrupt:
# Allow ctrl-c to end early and render a partial animation.
print(f"WARNING: Interrupted early, will render only {frameno}/{len(root_clip.source.frames)} frames of animation!")
# Clean up
self.__root = None