pax_global_header00006660000000000000000000000064144332501420014510gustar00rootroot0000000000000052 comment=dace1615604f13bd1a81bf94e31ad364734c6dba omgifol-0.5.1/000077500000000000000000000000001443325014200131475ustar00rootroot00000000000000omgifol-0.5.1/.gitignore000066400000000000000000000000501443325014200151320ustar00rootroot00000000000000*.pyc __pycache__ build dist *.egg-info omgifol-0.5.1/CHANGES000066400000000000000000000035601443325014200141460ustar00rootroot000000000000000.5.1 (2023/05/23) fix NameError regression in MapEditor.to_lumps [strategineer] 0.5.0 (2022/11/19) remove Python 2 support add support for UDMF maps (thanks to cyclopsian for the initial work) add support for Hexen 64x128 flats add 'listdir.py' demo script [Xymph] rewrite WAD struct generation to use ctypes fix more potential cases where map lumps would not be detected correctly fix Texture.simple not setting the new texture's name correctly [jmtd] 0.4.0 (2018/02/22) add support for importing/exporting/editing sound effect lumps add 'Thing' and 'Linedef' members to MapEditor class (aliases to the thing and linedef classes used by the current map format) add support for data stored inside map header lumps (e.g. FraggleScript) add ability for WAD and WadIO classes to use empty space in WAD file, if possible ('use_free' argument in relevant methods) enforce maximum size for patch lumps remove some old chr() calls for Python 3 users fix previously broken WAD.load method fix palette index 0 in patches becoming transparent when exporting fix some lumps falsely being loaded as maps (e.g. aaliens.wad "LOADACS", which was followed by a "SCRIPTS" lump and thus detected as a map) 0.3.0 (2017/10/06) add support for Python 3.x (experimental; 3.5.0 or higher recommended) add support for Hexen / ZDoom maps add better map loading (supports names other than ExMx and MAPxx, doesn't mistake MAPINFO for an actual map) add better support for "limit removing" maps add tall patch support add support for importing/exporting RGBA images (converted to the WAD's 256-color palette on import, but can contain true transparency) add better handling of missing map data add draw_sector and misc. helper functions to MapEditor [jmickle66666666] add ability to individually change single flags (by name) with MapEditor fix a colormap generation bug and add Colormap.set_position [jmickle66666666] omgifol-0.5.1/LICENSE000066400000000000000000000020671443325014200141610ustar00rootroot00000000000000Copyright (c) 2005 Fredrik Johansson, 2017 Devin Acker Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. omgifol-0.5.1/demo/000077500000000000000000000000001443325014200140735ustar00rootroot00000000000000omgifol-0.5.1/demo/drawmaps.py000066400000000000000000000044631443325014200162720ustar00rootroot00000000000000#!/usr/bin/env python from omg import * import sys from PIL import Image, ImageDraw def drawmap(wad, name, filename, width, format): xsize = width - 8 try: edit = UMapEditor(wad.udmfmaps[name]) except KeyError: edit = UMapEditor(wad.maps[name]) xmin = ymin = float('inf') xmax = ymax = float('-inf') for v in edit.vertexes: xmin = min(xmin, v.x) xmax = max(xmax, v.x) ymin = min(ymin, -v.y) ymax = max(ymax, -v.y) scale = xsize / float(xmax - xmin) xmax = xmax * scale xmin = xmin * scale ymax = ymax * scale ymin = ymin * scale for v in edit.vertexes: v.x = v.x * scale v.y = -v.y * scale im = Image.new('RGB', (int(xmax - xmin) + 8, int(ymax - ymin) + 8), (255,255,255)) draw = ImageDraw.Draw(im) edit.linedefs.sort(key=lambda a: not a.twosided) for line in edit.linedefs: p1x = edit.vertexes[line.v1].x - xmin + 4 p1y = edit.vertexes[line.v1].y - ymin + 4 p2x = edit.vertexes[line.v2].x - xmin + 4 p2y = edit.vertexes[line.v2].y - ymin + 4 color = (0, 0, 0) if line.twosided: color = (144, 144, 144) if line.special: color = (220, 130, 50) draw.line((p1x, p1y, p2x, p2y), fill=color) draw.line((p1x+1, p1y, p2x+1, p2y), fill=color) draw.line((p1x-1, p1y, p2x-1, p2y), fill=color) draw.line((p1x, p1y+1, p2x, p2y+1), fill=color) draw.line((p1x, p1y-1, p2x, p2y-1), fill=color) del draw im.save(filename, format) if (len(sys.argv) < 5): print("\n Omgifol script: draw maps to image files\n") print(" Usage:") print(" drawmaps.py source.wad pattern width format\n") print(" Draw all maps whose names match the given pattern (eg E?M4 or MAP*)") print(" to image files of a given format (PNG, BMP, etc). width specifies the") print(" desired width of the output images.") else: print("Loading %s..." % sys.argv[1]) inwad = WAD() inwad.from_file(sys.argv[1]) width = int(sys.argv[3]) format = sys.argv[4].upper() for name in inwad.maps.find(sys.argv[2]) + inwad.udmfmaps.find(sys.argv[2]): print("Drawing %s" % name) drawmap(inwad, name, name + "_map" + "." + format.lower(), width, format) omgifol-0.5.1/demo/listdir.py000077500000000000000000000017041443325014200161240ustar00rootroot00000000000000#!/usr/bin/python3 # list WAD directory with lump names/sizes or verbose info from Omgifol itself, # by Frans P. de Vries (Xymph) import sys, getopt from omg import * if len(sys.argv) < 2: print("\n Omgifol script: list WAD directory\n") print(" Usage:") print(" listdir.py [-v] source.wad\n") else: # process optional flag verbose = False try: opts, args = getopt.getopt(sys.argv[1:], 'v') for o, a in opts: if o == '-v': verbose = True except getopt.GetoptError as err: print(str(err)) sys.exit(2) # load WAD if os.path.exists(args[0]): wadio = WadIO(args[0]) # comprehensive info or simple list? if verbose: print(wadio.info_text()) else: for i, entry in enumerate(wadio.entries): print("%-8s %9d" % (entry.name, entry.size)) else: print("%s: no such file" % args[0]) omgifol-0.5.1/demo/merge.py000066400000000000000000000007631443325014200155520ustar00rootroot00000000000000#!/usr/bin/env python import omg, sys if (len(sys.argv) < 3): print("\n Omgifol script: merge WADs\n") print(" Usage:") print(" merge.py input1.wad input2.wad ... [-o output.wad]\n") print(" Default output is merged.wad") else: w = omg.WAD() for a in sys.argv[1:]: if a == "-o": break print("Adding %s..." % a) w += omg.WAD(a) outpath = "merged.wad" if "-o" in sys.argv: outpath = sys.argv[-1] w.to_file(outpath) omgifol-0.5.1/demo/mirror.py000066400000000000000000000022741443325014200157640ustar00rootroot00000000000000#!/usr/bin/env python from sys import argv from omg import * from omg.mapedit import * def mirror(map): ed = MapEditor(map) for v in ed.vertexes: v.x = -v.x for l in ed.linedefs: l.vx_a, l.vx_b = l.vx_b, l.vx_a for t in ed.things: t.x = -t.x t.angle = (180 - t.angle) % 360 ed.nodes = [] return ed.to_lumps() def main(args): if (len(args) < 2): print(" Omgifol script: mirror maps\n") print(" Usage:") print(" mirror.py input.wad output.wad [pattern]\n") print(" Mirror all maps or those whose name match the given pattern") print(" (eg E?M4 or MAP*).") print(" Note: nodes will have to be rebuilt externally.\n") else: print("Loading %s..." % args[0]) inwad = WAD() outwad = WAD() inwad.from_file(args[0]) pattern = "*" if (len(args) == 3): pattern = args[2] for name in inwad.maps.find(pattern): print("Mirroring %s" % name) outwad.maps[name] = mirror(inwad.maps[name]) print("Saving %s..." % args[1]) outwad.to_file(args[1]) if __name__ == "__main__": main(argv[1:]) omgifol-0.5.1/manual.html000066400000000000000000000252051443325014200153160ustar00rootroot00000000000000

Omgifol manual

Note: this is ridiculously incomplete.

Table of contents

Installation

  1. Install Python 3, which can be downloaded from https://python.org
  2. Use pip to install Omgifol: pip install omgifol
  3. Or, if pip is unavailable, extract the "omg" directory in the Omgifol package into pythondir/Lib/site-packages (replace pythondir with the directory where Python is installed).

Optionally:

  1. Install the Pillow library (https://python-pillow.github.io). This is required to import or export images.
  2. Install the PySoundFile library (https://pysoundfile.readthedocs.io). This is required to import or export sound files.

Using Omgifol

At the beginning of an interactive session, or as the first line in a Python script file, enter

 from omg import *

WAD objects

A WAD is an abstract representation of a WAD file. A WAD object can load content from a WAD file, or save content to a WAD file, but is entirely memory-resident.

Loading from WAD files

The following are all equivalent:

 a = WAD('wadfile.wad')

 a = WAD(from_file='wadfile.wad')

 f = open('wadfile.wad', 'rb')
 a = WAD(from_file=f)

 a = WAD()
 a.from_file('wadfile.wad')

 f = open('wadfile.wad', 'rb')
 a = WAD()
 a.from_file(f)

You can load more than one file to the same object:

 a = WAD()
 a.from_file(file1)
 a.from_file(file2)
 a.from_file(file3)

In this case, lumps from file2 will overwrite those from file1 with the same name, etc.

Writing to WAD files

If a is a WAD instance:

 a.to_file('some_wad.wad')

Accessing lumps

Lumps are stored in groups. Each WAD holds a number of groups, representing different categories of lumps. Each group is an ordered dictionary; that is, it works just like a Python collections.OrderedDict object.

All lumps are instances of the Lump class; see below for its documentation.

To retrieve the sprite called CYBR1A from the WAD object a, do:

   a.sprites['CYBR1A']

And to replace it with some other lump object called some_lump:

   a.sprites['CYBR1A'] = some_lump

To add a new lump, simply do as above with a lump name that does not yet exist.

Renaming and deleting is done as follows:

   a.sprites.rename('CYBR1A', 'NEW_NAME')
   del a.sprites['CYBR1A']

Lump groups

By default, WADs recognize the following lump groups:

   sprites             Sprite graphics (between S and SS markers)
   patches             Wall graphics (between P and PP markers)
   flats               Flat graphics (between F and FF markers)
   colormaps           Boom colormaps (between C markers)
   ztextures           ZDoom textures (between TX markers)
   maps                Map data
   udmfmaps            Map data (UDMF)
   glmaps              GL nodes map data
   music               Music (all lumps named D_*)
   sounds              Sound effects (all lumps named DS* or DP*)
   txdefs              TEXTURE1, TEXTURE2 and PNAMES
   graphics            Titlepic, status bar, miscellaneous graphics
   data                Everything else

This scheme can be modified if desired; refer to wad.py for the details.

The maps and glmaps are special. These do not contain lumps, but additional groups of lumps, one for each map. So if you access E1M1:

   a.maps['E1M1']

you will retrieve a group of lumps containing all the map's data. To retrieve the individual lumps, do:

   a.maps['E1M1']['SIDEDEFS']

etc.

Merging

To merge two WADs a and b:

 c = a + b

Note that (for efficiency reasons) this only copies references to lumps, which means that subsequent changes to lumps in a or b will affect the corresponding lumps in c. To give c its own set of lumps, do:

 c = (a + b).copy()

When lumps in a and b have the same name, lumps from b will replace those from a.

It is also possible to merge individual sections:

 a.sprites += b.sprites

Use with care for sections of different types.

Note that some sections do more than just copy over the list of lumps when they merge. For example, adding two txdefs sections together will automagically merge the TEXTURE1, TEXTURE2 and PNAMES lumps. txdefs also get merged this way when two WAD objects are merged on the top level.

Lumps

The Lump class holds a single lump. The class provides the following data and methods:

 .data                      The lump's raw data as a string
 .to_file(filename)         Save from a file
 .from_file(filename)       Load from a file
 .copy()                    Return a copy

Creating a new lump called 'FOOF' containing the text 'Hello!' and inserting it into a WAD w would be done as follows:

 w.data['FOOF'] = Lump('Hello!')

Graphic lumps

There are subclasses of Lump for different types of lumps. Currently, only these provide special functionality: Graphic, Flat, and Sound.

Graphic, used to represent Doom format graphics, provides the following settable attributes:

 .offsets              (x, y) offsets
 .x_offset             x offset
 .y_offset             y offset
 .dimensions           (width, height)
 .width                width in pixels
 .height               height in pixels

Graphic defines the following methods in adddition to those defined by Lump:

 .from_raw             Load from a raw image
 .to_raw               Return the image converted to raw pixels
 .from_Image           Load from a PIL Image instance
 .to_Image             Return the image converted to a PIL image
 .translate            Translate to another palette

For the argument lists used by these functions, refer to the code and the inline documentation in lump.py.

Flat works similarly to Graphic, but handles format conversions slightly differently.

Sound, used to represent Doom format sounds, provides the following settable attributes:

 .format               Sound effect format (0-3)
 .length               Length of sound in samples
 .sample_rate          Sample rate for digitized sounds (defaults to 11025)
 .midi_bank            MIDI patch bank number (formats 1-2 only)
 .midi_patch           MIDI patch number (formats 1-2 only)

Sound defines the following methods in adddition to those defined by Lump:

 .from_raw             Load from a raw sound file
 .to_raw               Return the sound file converted to raw samples
 .from_file            Load from a sound file
 .to_file              Save the sound to a file

Editors

Editors are used to edit lumps or lump groups. They represent lump data with high-level objects and structures, and provide methods to modify the data. The following editors have been implemented so far:

All editors provide the following methods:

   .to_lump
   .from_lump

or, if the editor represents more than one lump:

   .to_lumps
   .from_lumps

In the latter case, the editor is initialized with a lump group instead of a single lump.

Map editor

Example (moving one vertex one unit):

 m = MapEditor(wad.maps["E1M1"])
 m.vertexes[103].x += 1
 wad.maps["E1M1"] = m.to_lumps()

UDMF map editor

UMapEditor works similarly to MapEditor, except the attributes of map data are named based on how they appear in the TEXTMAP lump itself. See the UDMF specification for examples.

UMapEditor can also import non-UDMF maps, and line specials will automatically be translated to their UDMF equivalents when necessary:

 # Load and automatically convert a Doom-format map
 m = UMapEditor(wad.maps["E1M1"])

 # Load a UDMF-format map
 m = UMapEditor(wad.udmfmaps["MAP01"])

Refer to the source code for more information.

omgifol-0.5.1/omg/000077500000000000000000000000001443325014200137315ustar00rootroot00000000000000omgifol-0.5.1/omg/__init__.py000066400000000000000000000024701443325014200160450ustar00rootroot00000000000000""" Omgifol - a Python library for Doom WAD files Website: http://fredrikj.net/ Copyright (c) 2005 Fredrik Johansson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ __version__ = '0.5.0' __author__ = 'Devin Acker, Fredrik Johansson' from omg.wadio import * from omg.wad import * from omg.lump import * from omg.mapedit import * from omg.udmf import * omgifol-0.5.1/omg/colormap.py000066400000000000000000000044761443325014200161320ustar00rootroot00000000000000import omg.palette import omg.lump class Colormap: """An editor for Doom's COLORMAP lump. The colormap holds 34 tables of indices to the game's palette. The first 32 tables hold data for different brightness levels, the 33rd holds the indices used by the invulnerability powerup, and the 34th is unused.""" def __init__(self, from_lump=None): """Create new, optionally from an existing lump.""" self.tables = [[0 for x in range(256)] for y in range(34)] if from_lump: self.from_lump(from_lump) def build_fade(self, palette=None, fade=(0,0,0)): """Build fade tables. The default fade color is black; this may be overriden. Light color is not yet supported.""" palette = palette or omg.palette.default x, y, z = fade for n in range(32): e = 31-n for c in range(256): r, g, b = palette.colors[c] r = (r*n + x*e) // 32 g = (g*n + y*e) // 32 b = (b*n + z*e) // 32 self.tables[e][c] = palette.match((r,g,b)) def build_invuln(self, palette=None, start=(0,0,0), end=(255,255,255)): """Build range used by the invulnerability powerup.""" palette = palette or omg.palette.default ar, ag, ab = start br, bg, bb = end for i in range(256): bright = sum(palette.colors[i]) // 3 r = (ar*bright + br*(256-bright)) // 256 g = (ag*bright + bg*(256-bright)) // 256 b = (ab*bright + bb*(256-bright)) // 256 self.tables[32][i] = palette.match((r,g,b)) def from_lump(self, lump): """Load from a COLORMAP lump.""" assert len(lump.data) == 34*256 for n in range(34): self.tables[n] = [lump.data[i] for i in range(n*256,(n+1)*256)] def to_lump(self): """Pack to a COLORMAP lump.""" output = bytes() for t in self.tables: output += bytes(t) return omg.lump.Lump(output) # packed = [''.join([chr(c) for c in t]) for t in self.tables] # return omg.lump.Lump(''.join(packed)) def set_position(self,table,index,pal_index): """Sets a specified position in the colormap to the specified index in the playpal.""" self.tables[table][index] = pal_index omgifol-0.5.1/omg/lineinfo.py000066400000000000000000000472711443325014200161210ustar00rootroot00000000000000""" lineinfo.py -- dealing with Doom linedef trigger types. Provides functions to create a human-readable description code from a trigger number, and the inverse operation. Guide to Trigger Description Codes (R): Example: "FLOOR SR UP SLOW CRUSH LNC-8" Categories: DOOR - Doors (regular and locked) FLOOR - Floor movers CEIL - Ceiling movers PLAT - Platforms and lifts CRUSHER - Crushers STAIR - Stair builders ELEVATOR - Boom elevators LIGHT - Light changers EXIT - Exits TELEPORT - Teleports DONUT - Donuts (lower pillar, raise surrounding sector) TRANSFER - Transfer properties (Boom) SCROLL - Scroll lines and sectors (Boom) Trigger types: P1 - Push(door) trigger, works once PR - Push(door) trigger, works repeatedly S1 - Switch, works once SR - Switch, works repeatedly W1 - Walk across, works once WR - Walk across, works repeatedly G1 - Shoot, works once GR - Shoot, works repeatedly Door locks: YEL - Yellow key lock RED - Red key lock BLU - Blue key lock Door types: OWC - Open, Wait, Close CWO - Close, Wait, Open OSO - Open, Stay Open CSC - Close, Stay Closed Motion speed SLOW - Slow NORM - Normal FAST - Fast TURB - Turbo INST - Instant Delay times 3SEC - 3 seconds 4SEC - 4 seconds 30SEC - 30 seconds Sector property changers: CPYTEX - Copy Texture CPYTEX+DELTYPE - Copy Texture, Reset type CPYTEX+TYPE - Copy Texture and Type Directions: DOWN - Down UP - Up NOMOVE - Stay (only change properties) Miscellaneous: SECRET - A secret exit MONSTER - Monsters can activate the trigger LINE - Line teleporters REVERSE - Line teleporter, reversed SILENT - Make crushers or teleporters silent CRUSH - Enable crushing (for CEILs and FLOORs, not to be confused with CRUSHERs) Destinations/platforms: LNF - Lowest Neighbor Floor LNC - Lowest Neighbor Ceiling HNF - Highest Neighbor Floor HNC - Highest Neighbor Ceiling NNF - Next Neighbor Floor NNC - Next Neighbor Ceiling HNF+8 - 8 above Highest neighbor Floor LNC+8 - 8 under Lowest neighbor Ceiling F+8 - 8 above floor 8 - 8 units Absolute (STAIRs only) 16 - 16 units Absolute (STAIRs only) 24 - 24 units Absolute 32 - 32 units Absolute 512 - 512 units absolute SLT - Shortest Lower Texture SUT - Shortest Upper Texture NLF - Next Lowest neighbor Floor NLC - Next Lowest neighbor Ceiling NHF - Next Highest neighbor Floor CURRF - Current Floor (ELEVATORs) FLR - Floor CL - Ceiling NAF - Next adjacent floor PERP - Perpetual STOP - Stop ongoing motion Models: TRIG - Use trigger sector as model NUM - Lookup adjacent model numerically Lighting: 35 - 35 units 255 - 255 units MAXN - Maximum Neighbor MINN - Minimum Neighbor BLINK - Blinking Transfers (check boomref.txt for more info): FLIGHT - Transfer floor light level CLIGHT - Transfer ceiling light level TRANSLUCENCY - Transfer line translucency HEIGHTS - The famous 242! FRICTION - Transfer friction WIND - Transfer current POINTFORCE - Transfer force point (?) Scrollers (check boomref.txt for more info): CARRY - Carry objects (conveyor) WRTSECTOR - With respect to 1st side's sector ACCEL - Accelerate scrolling RIGHT - Right direction LEFT - Left direction WALL - Scroll wall SYNCED - Sync scrolling to sector OFFSETS - Scroll by offsets """ from fnmatch import fnmatchcase # Define description codes for the standard triggers desc2num = \ { "": 0, "NO ACTION":0, # Doors "DOOR PR SLOW OWC 4SEC MONSTER":1, "DOOR PR FAST OWC 4SEC":117, "DOOR SR SLOW OWC 4SEC":63, "DOOR SR FAST OWC 4SEC":114, "DOOR S1 SLOW OWC 4SEC":29, "DOOR S1 FAST OWC 4SEC":111, "DOOR WR SLOW OWC 4SEC":90, "DOOR WR FAST OWC 4SEC":105, "DOOR W1 SLOW OWC 4SEC":4, "DOOR W1 FAST OWC 4SEC":108, "DOOR P1 SLOW OSO":31, "DOOR P1 FAST OSO":118, "DOOR SR SLOW OSO":61, "DOOR SR FAST OSO":114, "DOOR S1 SLOW OSO":103, "DOOR S1 FAST OSO":112, "DOOR WR SLOW OSO":86, "DOOR WR FAST OSO":106, "DOOR W1 SLOW OSO":2, "DOOR W1 FAST OSO":109, "DOOR GR FAST OSO":46, "DOOR SR SLOW CSC":42, "DOOR SR FAST CSC":116, "DOOR S1 SLOW CSC":50, "DOOR S1 FAST CSC":113, "DOOR WR SLOW CSC":75, "DOOR WR FAST CSC":107, "DOOR W1 SLOW CSC":3, "DOOR W1 FAST CSC":110, "DOOR SR SLOW CWO 30SEC":196, "DOOR S1 SLOW CWO 30SEC":175, "DOOR WR SLOW CWO 30SEC":76, "DOOR W1 SLOW CWO 30SEC":16, "DOOR PR SLOW OWC 4SEC BLU":26, "DOOR PR SLOW OWC 4SEC RED":28, "DOOR PR SLOW OWC 4SEC YEL":27, "DOOR P1 SLOW OSO BLU":32, "DOOR P1 SLOW OSO RED":33, "DOOR P1 SLOW OSO YEL":34, "DOOR SR FAST OSO BLU":99, "DOOR SR FAST OSO RED":134, "DOOR SR FAST OSO YEL":136, "DOOR S1 FAST OSO BLU":133, "DOOR S1 FAST OSO RED":135, "DOOR S1 FAST OSO YEL":137, # Floors "FLOOR SR DOWN SLOW LNF":60, "FLOOR S1 DOWN SLOW LNF":23, "FLOOR WR DOWN SLOW LNF":82, "FLOOR W1 DOWN SLOW LNF":38, "FLOOR SR DOWN SLOW LNF CPYTEX+TYPE NUM":177, "FLOOR S1 DOWN SLOW LNF CPYTEX+TYPE NUM":159, "FLOOR WR DOWN SLOW LNF CPYTEX+TYPE NUM":84, "FLOOR W1 DOWN SLOW LNF CPYTEX+TYPE NUM":37, "FLOOR SR UP SLOW NNF":69, "FLOOR S1 UP SLOW NNF":18, "FLOOR WR UP SLOW NNF":128, "FLOOR W1 UP SLOW NNF":119, "FLOOR SR UP FAST NNF":132, "FLOOR S1 UP FAST NNF":131, "FLOOR WR UP FAST NNF":129, "FLOOR W1 UP FAST NNF":130, "FLOOR SR DOWN SLOW NNF":222, "FLOOR S1 DOWN SLOW NNF":221, "FLOOR WR DOWN SLOW NNF":220, "FLOOR W1 DOWN SLOW NNF":219, "FLOOR SR UP SLOW LNC":64, "FLOOR S1 UP SLOW LNC":101, "FLOOR WR UP SLOW LNC":91, "FLOOR W1 UP SLOW LNC":5, "FLOOR G1 UP SLOW LNC":24, "FLOOR SR UP SLOW LNC-8 CRUSH":65, "FLOOR S1 UP SLOW LNC-8 CRUSH":55, "FLOOR WR UP SLOW LNC-8 CRUSH":94, "FLOOR W1 UP SLOW LNC-8 CRUSH":56, "FLOOR SR DOWN SLOW HNF":45, "FLOOR S1 DOWN SLOW HNF":102, "FLOOR WR DOWN SLOW HNF":83, "FLOOR W1 DOWN SLOW HNF":19, "FLOOR SR DOWN FAST HNF+8":70, "FLOOR S1 DOWN FAST HNF+8":71, "FLOOR WR DOWN FAST HNF+8":98, "FLOOR W1 DOWN FAST HNF+8":36, "FLOOR SR UP SLOW 24":180, "FLOOR S1 UP SLOW 24":161, "FLOOR WR UP SLOW 24":92, "FLOOR W1 UP SLOW 24":58, "FLOOR SR UP SLOW 24 CPYTEX+TYPE TRIG":179, "FLOOR S1 UP SLOW 24 CPYTEX+TYPE TRIG":160, "FLOOR WR UP SLOW 24 CPYTEX+TYPE TRIG":93, "FLOOR W1 UP SLOW 24 CPYTEX+TYPE TRIG":59, "FLOOR SR UP SLOW SLT":176, "FLOOR S1 UP SLOW SLT":158, "FLOOR WR UP SLOW SLT":96, "FLOOR W1 UP SLOW SLT":30, "FLOOR SR UP SLOW 512":178, "FLOOR S1 UP SLOW 512":140, "FLOOR WR UP SLOW 512":147, "FLOOR W1 UP SLOW 512":142, "FLOOR SR NOMOVE CPYTEX+TYPE SLT TRIG":190, "FLOOR S1 NOMOVE CPYTEX+TYPE SLT TRIG":189, "FLOOR WR NOMOVE CPYTEX+TYPE SLT TRIG":154, "FLOOR W1 NOMOVE CPYTEX+TYPE SLT TRIG":153, "FLOOR SR NOMOVE CPYTEX+TYPE SLT NUM":78, "FLOOR S1 NOMOVE CPYTEX+TYPE SLT NUM":241, "FLOOR WR NOMOVE CPYTEX+TYPE SLT NUM":240, "FLOOR W1 NOMOVE CPYTEX+TYPE SLT NUM":239, # Ceilings "CEIL SR DOWN FAST FLR":43, "CEIL S1 DOWN FAST FLR":41, "CEIL WR DOWN FAST FLR":152, "CEIL W1 DOWN FAST FLR":145, "CEIL SR UP SLOW HNC":186, "CEIL S1 UP SLOW HNC":166, "CEIL WR UP SLOW HNC":151, "CEIL W1 UP SLOW HNC":40, "CEIL SR DOWN SLOW F+8":187, "CEIL S1 DOWN SLOW F+8":167, "CEIL WR DOWN SLOW F+8":72, "CEIL W1 DOWN SLOW F+8":44, "CEIL SR DOWN SLOW LNC":205, "CEIL S1 DOWN SLOW LNC":203, "CEIL WR DOWN SLOW LNC":201, "CEIL W1 DOWN SLOW LNC":199, "CEIL SR DOWN SLOW HNF":205, "CEIL S1 DOWN SLOW HNF":204, "CEIL WR DOWN SLOW HNF":202, "CEIL W1 DOWN SLOW HNF":200, # Platforms and lifts "PLAT SR SLOW CPYTEX TRIG 24":66, "PLAT S1 SLOW CPYTEX TRIG 24":15, "PLAT WR SLOW CPYTEX TRIG 24":148, "PLAT W1 SLOW CPYTEX TRIG 24":143, "PLAT SR SLOW CPYTEX TRIG 32":67, "PLAT S1 SLOW CPYTEX TRIG 32":14, "PLAT WR SLOW CPYTEX TRIG 32":149, "PLAT W1 SLOW CPYTEX TRIG 32":144, "PLAT SR SLOW CPYTEX+DELTYPE TRIG NAF":68, "PLAT S1 SLOW CPYTEX+DELTYPE TRIG NAF":20, "PLAT WR SLOW CPYTEX+DELTYPE TRIG NAF":95, "PLAT W1 SLOW CPYTEX+DELTYPE TRIG NAF":22, "PLAT G1 SLOW CPYTEX+DELTYPE TRIG NAF":47, "PLAT SR SLOW 3SEC PERP":181, "PLAT S1 SLOW 3SEC PERP":162, "PLAT WR SLOW 3SEC PERP":87, "PLAT W1 SLOW 3SEC PERP":53, "PLAT SR STOP":182, "PLAT S1 STOP":163, "PLAT WR STOP":89, "PLAT W1 STOP":54, "PLAT SR SLOW 3SEC LNF":62, "PLAT S1 SLOW 3SEC LNF":21, "PLAT WR SLOW 3SEC LNF":88, "PLAT W1 SLOW 3SEC LNF":10, "PLAT SR FAST 3SEC LNF":123, "PLAT S1 FAST 3SEC LNF":122, "PLAT WR FAST 3SEC LNF":120, "PLAT W1 FAST 3SEC LNF":121, "PLAT SR INST CL":211, "PLAT WR INST CL":212, # Crushers "CRUSHER SR SLOW":184, "CRUSHER S1 SLOW":49, "CRUSHER WR SLOW":73, "CRUSHER W1 SLOW":25, "CRUSHER SR FAST":183, "CRUSHER S1 FAST":164, "CRUSHER WR FAST":77, "CRUSHER W1 FAST":6, "CRUSHER SR SLOW SILENT":185, "CRUSHER S1 SLOW SILENT":165, "CRUSHER WR SLOW SILENT":150, "CRUSHER W1 SLOW SILENT":141, "CRUSHER SR STOP":188, "CRUSHER S1 STOP":168, "CRUSHER WR STOP":74, "CRUSHER W1 STOP":57, # Stairs "STAIR SR UP SLOW 8":258, "STAIR S1 UP SLOW 8":7, "STAIR WR UP SLOW 8":256, "STAIR W1 UP SLOW 8":8, "STAIR SR UP FAST 16":259, "STAIR S1 UP FAST 16":127, "STAIR WR UP FAST 16":257, "STAIR W1 UP FAST 16":100, # Boom elevators "ELEVATOR SR FAST NHF":230, "ELEVATOR S1 FAST NHF":229, "ELEVATOR WR FAST NHF":228, "ELEVATOR W1 FAST NHF":227, "ELEVATOR SR FAST NHF":234, "ELEVATOR S1 FAST NLF":233, "ELEVATOR WR FAST NLF":232, "ELEVATOR W1 FAST NLF":231, "ELEVATOR SR FAST CURRF":238, "ELEVATOR S1 FAST CURRF":237, "ELEVATOR WR FAST CURRF":236, "ELEVATOR W1 FAST CURRF":235, # Lighting "LIGHT SR 35":139, "LIGHT S1 35":170, "LIGHT WR 35":79, "LIGHT W1 35":35, "LIGHT SR 255":138, "LIGHT S1 255":171, "LIGHT WR 255":81, "LIGHT W1 255":13, "LIGHT SR MAXN":192, "LIGHT S1 MAXN":169, "LIGHT WR MAXN":80, "LIGHT W1 MAXN":12, "LIGHT SR MINN":194, "LIGHT S1 MINN":173, "LIGHT WR MINN":157, "LIGHT W1 MINN":104, "LIGHT SR BLINK":193, "LIGHT S1 BLINK":172, "LIGHT WR BLINK":156, "LIGHT W1 BLINK":17, # Exits "EXIT S1":11, "EXIT W1":52, "EXIT G1":197, "EXIT S1 SECRET":51, "EXIT W1 SECRET":124, "EXIT G1 SECRET":198, # Teleports "TELEPORT SR":195, "TELEPORT S1":174, "TELEPORT WR":97, "TELEPORT W1":39, "TELEPORT WR MONSTER":126, "TELEPORT W1 MONSTER":125, "TELEPORT SR MONSTER":269, "TELEPORT S1 MONSTER":268, "TELEPORT SR SILENT":210, "TELEPORT S1 SILENT":209, "TELEPORT WR SILENT":208, "TELEPORT W1 SILENT":207, "TELEPORT WR SILENT LINE":244, "TELEPORT W1 SILENT LINE":243, "TELEPORT WR SILENT LINE REVERSE":263, "TELEPORT W1 SILENT LINE REVERSE":262, "TELEPORT WR SILENT LINE MONSTER":267, "TELEPORT W1 SILENT LINE MONSTER":266, "TELEPORT WR SILENT LINE REVERSE MONSTER":265, "TELEPORT W1 SILENT LINE REVERSE MONSTER":264, # Donuts "DONUT SR":191, "DONUT S1":9, "DONUT WR":155, "DONUT W1":146, # Boom property transfer "TRANSFER FLIGHT":213, "TRANSFER CLIGHT":261, "TRANSFER TRANSLUCENCY":260, "TRANSFER HEIGHTS":242, "TRANSFER FRICTION":223, "TRANSFER WIND":224, "TRANSFER CURRENT":225, "TRANSFER POINTFORCE":226, # Scrollers "SCROLL CL":250, "SCROLL FLR":251, "SCROLL CARRY":252, "SCROLL FLR+CARRY":253, "SCROLL WALL SYNCED":254, "SCROLL WALL OFFSETS":255, "SCROLL WALL RIGHT":85, "SCROLL WALL LEFT":48, "SCROLL CL WRTSECTOR":245, "SCROLL FLR WRTSECTOR":246, "SCROLL CARRY WRTSECTOR":247, "SCROLL F+CARRY WRTSECTOR":248, "SCROLL WALL WRTSECTOR":249, "SCROLL CL ACCEL":214, "SCROLL FLR ACCEL":215, "SCROLL CARRY ACCEL":216, "SCROLL FLR+CARRY ACCEL":217, "SCROLL WALL ACCEL":218 } num2desc = {} for d, n in desc2num.items(): num2desc[n] = d del(d) del(n) trigcompat = \ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] def check_compat(num): """Check the compatibility for a trigger number.""" if 8192 <= num < 32768: return "BOOM GENERALIZED" try: return ["UNKNOWN", "DOOM19", "BOOM EXTENDED"][trigcompat[num]] except: return "UNKNOWN" def decode(n): """Generate a description code for a number.""" d = [] if n < 8192: if n in num2desc: return num2desc[n] return "UNKNOWN" # Boom generalized elif 0x2F80 <= n < 0x3000: n -= 0x2F80 d += ["CRUSHER"] d += [("W1","WR","S1","SR","G1","GR","P1","PR") [n&0x0007]] d += [("SLOW","NORMAL","FAST","TURBO") [(n&0x0018)>>3]] d += [("MONSTER","") [(n&0x0020)>>5]] d += [("SILENT","") [(n&0x00c0)>>6]] elif 0x3000 <= n < 0x3400: n -= 0x3000 d += ["STAIR"] d += [("W1","WR","S1","SR","G1","GR","P1","PR") [n&0x0007]] d += [("SLOW","NORMAL","FAST","TURBO") [(n&0x0018)>>3]] d += [("","MONSTER") [(n&0x0020)>>5]] d += [("4","8","16","24") [(n&0x00c0)>>6]] d += [("DOWN","UP") [(n&0x0100)>>8]] d += [("", "IGNTXT") [(n&0x0200)>>9]] elif 0x3400 <= n < 0x3800: n -= 0x3400 d += ["PLATFORM"] d += [("W1","WR","S1","SR","G1","GR","P1","PR") [n&0x0007]] d += [("SLOW","NORMAL","FAST","TURBO") [(n&0x0018)>>3]] d += [("MONSTER","") [(n&0x0020)>>5]] d += [("1","3","5","10") [(n&0x00c0)>>6]] d += [("LNF","NNF","LNC","PERP") [(n&0x0300)>>8]] elif 0x3800 <= n < 0x3c00: n -= 0x3800 d += ["DOOR"] d += [("W1","WR","S1","SR","G1","GR","P1","PR") [n&0x0007]] d += [("SLOW","NORMAL","FAST","TURBO") [(n&0x0018)>>3]] d += [("OWC","OSO") [(n&0x0020)>>5]] d += [("ANY","RED","YELLOW","BLUE","RED", "BLUE","YELLOW","ALL") [(n&0x01c0)>>6]] d += [("3KEYS","6KEYS") [(n&0x0200)>>9]] elif 0x3c00 <= n < 0x4000: n -= 0x3c00 d += ["DOOR"] d += [("W1","WR","S1","SR","G1","GR","P1","PR") [n&0x0007]] d += [("SLOW","NORMAL","FAST","TURBO") [(n&0x0018)>>3]] d += [("OWC","OSO","CWO","CSC") [(n&0x0060)>>5]] d += [("MONSTER","") [(n&0x0080)>>7]] d += [("1SECS","4SECS","9SECS","30SECS") [(n&0x0300)>>8]] elif 0x4000 <= n < 0x6000: n -= 0x4000 d += ["CEIL"] d += [("W1","WR","S1","SR","G1","GR","P1","PR") [n&0x0007]] d += [("SLOW","NORMAL","FAST","TURBO") [(n&0x0018)>>3]] d += [("TRIG","NUM") [(n&0x0020)>>5]] d += [("DOWN","UP") [(n&0x0040)>>6]] d += [("HNC","LNC","NNC","HNF","FLR", "SUT","24","32") [(n&0x0380)>>7]] d += [("","CPYTEX+DELTYPE","CPYTEX","CHGTYPE") [(n&0x0c00)>>10]] d += [("CRUSH","") [(n&0x1000)>>12]] elif 0x6000 <= n < 0x8000: n -= 0x6000 d += ["FLOOR"] d += [("W1","WR","S1","SR","G1","GR","P1","PR") [n&0x0007]] d += [("SLOW","NORMAL","FAST","TURBO") [(n&0x0018)>>3]] d += [("TRIG","NUM") [(n&0x0020)>>5]] d += [("DOWN","UP") [(n&0x0040)>>6]] d += [("HNF","LNF","NNF","LNC","CL", "SLT","24","32") [(n&0x0380)>>7]] d += [("","CPYTEX+DELTYPE","CPYTEX","CHGTYPE") [(n&0x0c00)>>10]] d += [("CRUSH","") [(n&0x1000)>>12]] # Bit of a hack, but works return (" ".join(d)).replace(" "," ").rstrip(" ") def encode_std(desc): """Encode an exact description of a trigger into its corresponding number. For inexact descriptions, use find_std.""" try: return desc2num[desc.upper()] except: raise Exception("Description not recognized") def encode_gen(desc): """Encode a generalized (Boom) trigger description to a trigger number. Invalid or incompatible terms get converted to the default value.""" desc = desc.upper() num = 0 def pk(seq, shift): for i in range(len(seq)): if seq[i] in desc: return i << shift return 0 num |= pk(("W1","WR","S1","SR","G1","GR","P1","PR"), 0) num |= pk(("SLOW","NORMAL","FAST","TURBO"), 3) if ("FLOOR" in desc) or ("CEIL" in desc): num |= pk(("TRIG","NUM"), 5) num |= pk(("DOWN","UP"), 6) num |= pk(("xx","CPYTEX+DELTYPE","CPYTEX","CHGTYPE"), 10) num |= pk(("CRUSH",), 12) if "FLOOR" in desc: num |= pk(("HNF","LNF","NNF","LNC","CL","SLT","24","32"), 7) num += 0x6000 else: num |= pk(("HNC","LNC","NNC","HNF","FLR","SUT","24","32"), 7) num += 0x4000 elif "CRUSHER" in desc: num |= pk(("MONSTER",), 5) num |= pk(("SILENT",), 6) num += 0x2F80 elif "STAIR" in desc: num |= pk(("xx","MONSTER"), 5) num |= pk(("4","8","16","24"), 6) num |= pk(("DOWN","UP"), 8) num |= pk(("xx","IGNTXT"), 9) num += 0x3000 elif "PLATFORM" in desc: num |= pk(("MONSTER",), 5) num |= pk(("1","3","5","10"), 6) num |= pk(("LNF","NNF","LNC","PERP"), 8) num += 0x3400 elif "DOOR" in desc: num |= pk(("SLOW","NORMAL","FAST","TURBO"), 3) if ("BLU" in desc) or ("YEL" in desc) or ("RED" in desc) or\ ("ALL" in desc) or ("ANY" in desc): num |= pk(("OWC","OSO"), 5) num |= pk(("ANY","RED","YELLOW","BLUE","RED","BLUE","YELLOW","ALL"), 6) num |= pk(("3KEYS","6KEYS"), 9) num += 0x3800 else: num |= pk(("OWC","OSO","CWO","CSC"), 5) num |= pk(("MONSTER",), 7) num |= pk(("1SECS","4SECS","9SECS","30SECS"), 8) num += 0x3c00 else: raise LookupError("Insufficient information provided") return num def find_std(desc): """Search the standard (non-generalized) triggers. A list of matches is returned. All terms must match. Wildcards are allowed. Example: find_std("CEIL UP S?") should return: ['CEIL S1 UP SLOW HNC', 'CEIL SR UP SLOW HNC']""" desc = desc.upper() terms = desc.split() matches = [] for dsc in num2desc.values(): d = dsc.split() matchedterms = 0 for term in terms: for key in d: if fnmatchcase(key, term): matchedterms += 1 if matchedterms == len(terms): matches.append(dsc) return matches __all__ = [find_std, encode_std, encode_gen, decode, check_compat] omgifol-0.5.1/omg/lump.py000066400000000000000000000576731443325014200153020ustar00rootroot00000000000000# Import the Python Imaging Library if it is available. On error, ignore # the problem and continue. PIL being absent should only affect the # graphic lump loading/saving methods and the user may not be interested # in installing PIL just to pass this line if not interested in using the # graphics functionality at all. try: from PIL import Image, ImageDraw, ImageOps except: pass # Import PySoundFile for sound file loading/saving. Equally optional. try: from soundfile import SoundFile, check_format import numpy as np except: pass import os import omg.palette from omg.util import * class Lump(object): """Basic lump class. Instances of Lump (and its subclasses) always have the following: .data -- a bytes object holding the lump's data .from_file -- load the data to a file .to_file -- save the data to a file The default Lump class merely copies the raw data when loading/saving to files, but subclasses may convert data appropriately (for example, Graphic supports various image formats).""" def __init__(self, data=None, from_file=None): """Create a new instance. The `data` parameter may be a string representing data for the lump. The `source` parameter may be a path to a file or a file-like object to load from.""" self.data = bytes() if issubclass(type(data), Lump): self.data = data.data elif data is not None: self.data = data or bytes() if from_file: self.from_file(from_file) def from_file(self, source): """Load data from a file. Source may be a path name string or a file-like object (with a `write` method).""" self.data = readfile(source) def to_file(self, target): """Write data to a file. Target may be a path name string or a file-like object (with a `write` method).""" writefile(target, self.data) def copy(self): return deepcopy(self) class Music(Lump): """Subclass of Lump, for music lumps. Not yet implemented.""" pass class Sound(Lump): """Subclass of Lump, for Doom format sounds. Supports conversion from/to RAWs (sequences of bytes), as well as saving to/loading from various file formats (via PySoundFile). Useful attributes: .format -- DMX sound format .length -- (in frames/samples) .sample_rate -- .midi_bank -- MIDI patch bank (format 1/2 only) .midi_patch -- MIDI patch number (format 1/2 only) Possible values for the 'format' attribute: 0: PC speaker sound Raw data consists of values 0-127 corresponding to pitch. Sample rate is fixed at 140Hz. 1: MIDI sound sequence Raw data consists of MIDI note and pitch bend info. Sample rate is fixed at 140Hz. 2: MIDI note Raw data consists of a single MIDI note. Sample rate is undefined. Length is MIDI note length. 3: Digitized sound (default) Raw data is 8-bit unsigned PCM. Sample rate defaults to 11025 Hz, but can be changed. Only format 3 can be exported to an audio file. """ def __init__(self, data=None, from_file=None): Lump.__init__(self, data, from_file) # default to an empty digitized sound effect if no data loaded try: if self.format is None: self.format = 3 except TypeError: pass def get_format(self): """Retrieve the format of the sound.""" if len(self.data) < 2: format = None else: format = unpack(' 3: raise TypeError("Unknown or invalid sound format") return format def set_format(self, format): """Change the format of the sound. Warning: Changing a sound's format will erase any existing sound data!""" try: if format == self.format: return # don't do anything if format is the same as before except TypeError: pass if format == 0: # PC speaker sound self.data = pack(' 65535: raise ValueError("sound effect length must be between 0-65535") if format == 2: # single MIDI note self.data = self.data[:8] + pack(' 65535: raise ValueError("sound effect length must be between 0-65535") # optionally change format if needed if format is None: format = self.format else: self.format = format if format == 0: # PC speaker sound self.data = self.data[:2] + pack('> 8) + 128 if file.channels > 1: sound = np.mean(sound, axis=1) # create new format 3 sound self.from_raw(sound.astype('uint8').tobytes(), 3, file.samplerate) def to_file(self, filename, subtype='PCM_U8'): """Save the sound to an audio file. The output format is selected based on the filename extension. For example, "file.wav" saves to WAV format. If the file has no extension, WAV format is used. See the PySoundFile documentation for possible values of 'subtype'. Possible values depend on the output format; if the given value is not supported, the format's default will be used. Special cases: ".lmp" saves the raw lump data, and ".raw" saves the raw sound data. """ format = os.path.splitext(filename)[1][1:].upper() or 'WAV' if format == 'LMP': writefile(filename, self.data) elif format == 'RAW': writefile(filename, self.to_raw()) elif self.format == 3: if check_format(format, subtype): pass elif check_format(format, 'PCM_U8'): subtype = 'PCM_U8' elif check_format(format, 'PCM_S8'): subtype = 'PCM_S8' else: subtype = None # use default for format with SoundFile(filename, 'w', self.sample_rate, 1, subtype, format=format) as file: # convert to signed 16-bit (since SoundFile doesn't directly support 8-bit input) # the result will just be converted back in the file though sound = (np.frombuffer(self.to_raw(), dtype='uint8').astype('int16') - 128) << 8 file.write(sound) else: raise TypeError("audio file export only supported for digitized sounds (format 3)") class Graphic(Lump): """Subclass of Lump, for Doom format graphics. Supports conversion from/to RAWs (sequences of bytes) and PIL Image objects, as well as saving to/loading from various file formats (via PIL). Useful attributes: .dimensions -- (width, height) .width -- width of the image .height -- height of the image .x_offset -- x offset .y_offset -- y offset """ def __init__(self, data=None, from_file=None, palette=None): self.palette = palette or omg.palette.default Lump.__init__(self, data, from_file) def get_offsets(self): """Retrieve the (x, y) offsets of the graphic.""" return unpack(' 32767: raise ValueError("image width and height must be between 0-32767") # First pass: extract pixel data in column+post format columns_in = [data[n:width*height:width] for n in range(width)] columns_out = [] for column in columns_in: # Find the y position where each chunk starts start_rows = [] postdata = [] in_trans = True tall = False offset = 0 for y in range(height): # split at 128 for vanilla-compatible images without premature tiling if height < 256: if y == 128: in_trans = True # for tall patch support elif offset == 254: in_trans = True tall = True # dummy post start_rows.append(254) postdata.append(bytearray()) # start relative offsets offset = 0 if column[y] is None: in_trans = True else: if in_trans: # start a new post start_rows.append(offset) postdata.append(bytearray()) in_trans = False if tall: # reset relative offset for tall patches offset = 0 postdata[-1].append(column[y]) offset += 1 columns_out.append(zip(start_rows, postdata)) # Second pass: compile column+post data, adding pointers data = [] columnptrs = [] pointer = 4*width + 8 for column in columns_out: columnptrs.append(pack('= len(data): continue while data[pointer] != 0xff: offset = data[pointer] if offset <= y: y += offset # for tall patches else: y = offset post_length = data[pointer + 1] op = y*width + x for p in range(pointer + 3, pointer + post_length + 3): if op >= len(output) or p >= len(data): break output[op] = data[p] op += width pointer += post_length + 4 return output def to_raw(self, tran_index=None): """Returns self converted to a raw (8-bpp) image. `tran_index` specifies the palette index to use for transparent pixels. The value defaults to that of the Graphic object's palette instance. """ tran_index = tran_index or self.palette.tran_index output = [i if i is not None else tran_index for i in self.to_pixels()] return bytes(bytearray(output)) def to_Image(self, mode='P'): """Convert to a PIL Image instance.""" if mode != 'RGBA' or isinstance(self, Flat): # target image has no alpha, # or source image is a flat (which has no transparent pixels) im = Image.new('P', self.dimensions, None) if isinstance(self, Flat): im.frombytes(self.data) else: im.frombytes(self.to_raw()) im.putpalette(self.palette.save_bytes) return im.convert(mode) else: # target image is RGBA and source image is not a flat im = Image.new('RGBA', self.dimensions, None) data = bytes().join([self.palette.bytes[i*3:i*3+3] + b'\xff' if i is not None \ else b'\0\0\0\0' for i in self.to_pixels()]) im.frombytes(data) return im def from_Image(self, im, translate=False): """Load from a PIL Image instance. If the input image is 24-bit or 32-bit, the colors will be looked up in the current palette. If the input image is 8-bit, indices will simply be copied from the input image. To properly translate colors between palettes, set the `translate` parameter. """ pixels = im.tobytes() width, height = im.size xoff, yoff = (width // 2)-1, height-5 if im.mode == "RGB": pixels = bytes([self.palette.match(unpack('BBB', \ pixels[i*3:(i+1)*3])) for i in range(width*height)]) self.from_raw(pixels, width, height, xoff, yoff, self.palette) elif im.mode == "RGBA": pixels = [unpack('BBBB', pixels[i*4:(i+1)*4]) for i in range(width*height)] pixels = [self.palette.match(i[0:3]) if i[3] > 0 else None for i in pixels] self.from_pixels(pixels, width, height, xoff, yoff) elif im.mode == 'P': srcpal = im.palette.tobytes() if im.palette.mode == "RGB": palsize = 3 elif im.palette.mode == "RGBA": palsize = 4 else: raise TypeError("palette mode must be 'RGB' or 'RGBA'") if translate: R = [c for c in srcpal[0::palsize]] G = [c for c in srcpal[1::palsize]] B = [c for c in srcpal[2::palsize]] srcpal = zip(R, G, B) lexicon = [self.palette.match(c) for c in srcpal] pixels = bytes([lexicon[b] for b in pixels]) else: # Simply copy pixels. However, make sure to translate # all colors matching the transparency color to the # right index. This is necessary because programs # aren't consistent in choice of position for the # transparent entry. packed_color = pack("BBB", *self.palette.tran_color) packed_index = pack("B", self.palette.tran_index) ri = 0 while ri != -1: ri = srcpal.find(packed_color, ri+palsize) if not ri % palsize and ri//palsize != self.palette.tran_index: pixels = pixels.replace(pack("B", ri//palsize), packed_index) self.from_raw(pixels, width, height, xoff, yoff, self.palette) else: raise TypeError("image mode must be 'P', 'RGB', or 'RGBA'") def from_file(self, filename, translate=False): """Load graphic from an image file.""" if filename[-4:].lower() == '.lmp': self.data = readfile(filename) else: im = Image.open(filename) self.from_Image(im, translate) def to_file(self, filename, mode='P'): """Save the graphic to an image file. The output format is selected based on the filename extension. For example, "file.jpg" saves to JPEG format. If the file has no extension, PNG format is used. Special cases: ".lmp" saves the raw lump data, and ".raw" saves the raw pixel data. `mode` may be be 'P', 'RGB', or 'RGBA' for palette or 24/32 bit output, respectively. However, .raw ignores this parameter and always writes in palette mode. """ format = os.path.splitext(filename)[1][1:].upper() if format == 'LMP': writefile(filename, self.data) elif format == 'RAW': writefile(filename, self.to_raw()) else: im = self.to_Image(mode) if format: im.save(filename) else: im.save(filename, "PNG") def translate(self, pal): """Translate (in-place) the graphic to another palette.""" lexicon = [pal.match(self.palette.colors[i]) for i in range(256)] lexicon[self.palette.tran_index] = pal.tran_index if isinstance(self, Flat): self.data = bytes([lexicon[b] for b in self.data]) else: raw = self.to_raw() self.load_raw(bytes([lexicon[b] for b in raw]), self.width, self.height, self.x_offset, self.y_offset) class Flat(Graphic): """Subclass of Graphic, for flat graphics.""" def get_dimensions(self): sz = len(self.data) if sz == 4096: return (64, 64) if sz == 4160: return (64, 65) if sz == 8192: return (64, 128) root = int(sz**0.5) if root**2 != sz: raise TypeError("unable to determine size: not a square number") return (root, root) dimensions = property(get_dimensions) width = property(lambda self: self.dimensions[0]) height = property(lambda self: self.dimensions[1]) def load_raw(self, data, *unused): self.data = data def to_raw(self): return self.data omgifol-0.5.1/omg/mapedit.py000066400000000000000000000422511443325014200157320ustar00rootroot00000000000000from omg.util import * from omg.lump import * from omg.wad import NameGroup import omg.lineinfo as lineinfo import omg.thinginfo as thinginfo class Vertex(WADStruct): """Represents a map vertex.""" _fields_ = [ ("x", ctypes.c_int16), ("y", ctypes.c_int16) ] class GLVertex(WADStruct): """Represents a map GL vertex.""" _fields_ = [ ("x", ctypes.c_int32), ("y", ctypes.c_int32) ] class Sidedef(WADStruct): """Represents a map sidedef.""" _fields_ = [ ("off_x", ctypes.c_int16), ("off_y", ctypes.c_int16), ("tx_up", ctypes.c_char * 8), ("tx_low", ctypes.c_char * 8), ("tx_mid", ctypes.c_char * 8), ("sector", ctypes.c_uint16) ] def __init__(self, *args, **kwargs): self.tx_up = self.tx_low = self.tx_mid = "-" super().__init__(*args, **kwargs) class Linedef(WADStruct): """ Represents a map linedef. Linedef.NONE is the placeholder value for unused sidedefs. Using the value -1 is no longer supported. """ NONE = 0xffff _flags_ = [ ("impassable", 1), ("block_monsters", 1), ("two_sided", 1), ("upper_unpeg", 1), ("lower_unpeg", 1), ("secret", 1), ("block_sound", 1), ("invisible", 1), ("automap", 1) ] _fields_ = [ ("vx_a", ctypes.c_uint16), ("vx_b", ctypes.c_uint16), ("flags", WADFlags(_flags_)), ("action", ctypes.c_uint16), ("tag", ctypes.c_uint16), ("front", ctypes.c_uint16), ("back", ctypes.c_uint16) ] _anonymous_ = ("flags",) def __init__(self, *args, **kwargs): self.front = self.back = Linedef.NONE super().__init__(*args, **kwargs) # TODO: an enum or something for triggers class ZLinedef(WADStruct): """ Represents a map linedef (Hexen / ZDoom). Linedef.NONE is the placeholder value for unused sidedefs. Using the value -1 is no longer supported. """ _flags_ = [ ("impassable", 1), ("block_monsters", 1), ("two_sided", 1), ("upper_unpeg", 1), ("lower_unpeg", 1), ("secret", 1), ("block_sound", 1), ("invisible", 1), ("automap", 1), ("repeat", 1), ("trigger", 3), ("activate_any", 1), ("dummy", 1), ("block_all", 1) ] _fields_ = [ ("vx_a", ctypes.c_uint16), ("vx_b", ctypes.c_uint16), ("flags", WADFlags(_flags_)), ("action", ctypes.c_ubyte), ("arg0", ctypes.c_ubyte), ("arg1", ctypes.c_ubyte), ("arg2", ctypes.c_ubyte), ("arg3", ctypes.c_ubyte), ("arg4", ctypes.c_ubyte), ("front", ctypes.c_uint16), ("back", ctypes.c_uint16) ] _anonymous_ = ("flags",) def __init__(self, *args, **kwargs): self.front = self.back = Linedef.NONE super().__init__(*args, **kwargs) class Thing(WADStruct): """Represents a map thing.""" _flags_ = [ ("easy", 1), ("medium", 1), ("hard", 1), ("deaf", 1), ("multiplayer", 1) ] _fields_ = [ ("x", ctypes.c_int16), ("y", ctypes.c_int16), ("angle", ctypes.c_uint16), ("type", ctypes.c_uint16), ("flags", WADFlags(_flags_)) ] _anonymous_ = ("flags",) class ZThing(WADStruct): """Represents a map thing (Hexen / ZDoom).""" _flags_ = [ ("easy", 1), ("medium", 1), ("hard", 1), ("deaf", 1), ("dormant", 1), ("fighter", 1), ("cleric", 1), ("mage", 1), ("solo", 1), ("multiplayer", 1), ("deathmatch", 1) ] _fields_ = [ ("tid", ctypes.c_uint16), ("x", ctypes.c_int16), ("y", ctypes.c_int16), ("height", ctypes.c_int16), ("angle", ctypes.c_uint16), ("type", ctypes.c_uint16), ("flags", WADFlags(_flags_)), ("action", ctypes.c_ubyte), ("arg0", ctypes.c_ubyte), ("arg1", ctypes.c_ubyte), ("arg2", ctypes.c_ubyte), ("arg3", ctypes.c_ubyte), ("arg4", ctypes.c_ubyte) ] _anonymous_ = ("flags",) class Sector(WADStruct): """Represents a map sector.""" _fields_ = [ ("z_floor", ctypes.c_int16), ("z_ceil", ctypes.c_int16), ("tx_floor", ctypes.c_char * 8), ("tx_ceil", ctypes.c_char * 8), ("light", ctypes.c_uint16), ("type", ctypes.c_uint16), ("tag", ctypes.c_uint16) ] def __init__(self, *args, **kwargs): self.z_ceil = 128 self.light = 160 self.tx_floor = "FLOOR4_8" self.tx_ceil = "CEIL3_5" super().__init__(*args, **kwargs) class Node(WADStruct): """Represents a BSP tree node.""" _fields_ = [ ("x_start", ctypes.c_int16), ("y_start", ctypes.c_int16), ("x_vector", ctypes.c_int16), ("y_vector", ctypes.c_int16), ("right_bbox_top", ctypes.c_int16), ("right_bbox_bottom", ctypes.c_int16), ("right_bbox_left", ctypes.c_int16), ("right_bbox_right", ctypes.c_int16), ("left_bbox_top", ctypes.c_int16), ("left_bbox_bottom", ctypes.c_int16), ("left_bbox_left", ctypes.c_int16), ("left_bbox_right", ctypes.c_int16), ("right_index", ctypes.c_uint16), ("left_index", ctypes.c_uint16) ] class Seg(WADStruct): """Represents a map seg.""" _fields_ = [ ("vx_a", ctypes.c_uint16), ("vx_b", ctypes.c_uint16), ("angle", ctypes.c_uint16), ("line", ctypes.c_uint16), ("side", ctypes.c_uint16), ("offset", ctypes.c_uint16) ] class GLSeg(WADStruct): """Represents a map GL seg.""" _fields_ = [ ("vx_a", ctypes.c_uint16), ("vx_b", ctypes.c_uint16), ("line", ctypes.c_uint16), ("side", ctypes.c_uint16), ("partner", ctypes.c_uint16) ] class SubSector(WADStruct): """Represents a map subsector.""" _fields_ = [ ("numsegs", ctypes.c_uint16), ("seg_a", ctypes.c_uint16) ] class MapEditor: """Doom map editor. Data members: header Lump object consisting of data in map header (if any) vertexes List containing Vertex objects sidedefs List containing Sidedef objects linedefs List containing Linedef or ZLinedef objects sectors List containing Sector objects things List containing Thing or ZThing objects Data members (Hexen/ZDoom formats only): behavior Lump object containing compiled ACS scripts scripts Lump object containing ACS script source Other members: Thing alias to Thing or ZThing class, depending on format Linedef alias to Linedef or ZLinedef class, depending on format Currently present but unused: segs List containing Seg objects ssectors List containing SubSector objects nodes List containing Node objects blockmap Lump object containing blockmap data reject Lump object containing reject table data (These five lumps are not updated when saving; you will need to use an external node builder utility) """ def __init__(self, from_lumps=None): """Create new, optionally from a lump group.""" if from_lumps is not None: self.from_lumps(from_lumps) else: self.Thing = Thing self.Linedef = Linedef self.header = Lump() self.vertexes = [] self.sidedefs = [] self.linedefs = [] self.sectors = [] self.things = [] self.segs = [] self.ssectors = [] self.nodes = [] self.blockmap = Lump("") self.reject = Lump("") def _unpack_lump(self, class_, data): s = ctypes.sizeof(class_) return [class_(bytes=data[i:i+s]) for i in range(0,len(data),s)] def from_lumps(self, lumpgroup): """Load entries from a lump group.""" m = lumpgroup try: self.header = m["_HEADER_"] self.vertexes = self._unpack_lump(Vertex, m["VERTEXES"].data) self.sidedefs = self._unpack_lump(Sidedef, m["SIDEDEFS"].data) self.sectors = self._unpack_lump(Sector, m["SECTORS"].data) if "BEHAVIOR" in m: # Hexen / ZDoom map self.Thing = ZThing self.Linedef = ZLinedef self.behavior = m["BEHAVIOR"] # optional script sources try: self.scripts = m[wcinlist(m, "SCRIPT*")[0]] except IndexError: self.scripts = Lump() else: self.Thing = Thing self.Linedef = Linedef try: del self.behavior del self.scripts except AttributeError: pass self.things = self._unpack_lump(self.Thing, m["THINGS"].data) self.linedefs = self._unpack_lump(self.Linedef, m["LINEDEFS"].data) except KeyError as e: raise ValueError("map is missing %s lump" % e) from struct import error as StructError try: self.ssectors = self._unpack_lump(SubSector, m["SSECTORS"].data) self.segs = self._unpack_lump(Seg, m["SEGS"].data) self.nodes = self._unpack_lump(Node, m["NODES"].data) self.blockmap = m["BLOCKMAP"] self.reject = m["REJECT"] except (KeyError, StructError): # nodes failed to build - we don't really care # TODO: this also "handles" (read: ignores) expanded zdoom nodes) self.ssectors = [] self.segs = [] self.nodes = [] self.blockmap = Lump() self.reject = Lump() def load_gl(self, mapobj): """Load GL nodes entries from a map.""" vxdata = mapobj["GL_VERT"].data[4:] # s[:4] == "gNd3" ? self.gl_vert = self._unpack_lump(GLVertex, vxdata) self.gl_segs = self._unpack_lump(GLSeg, mapobj["GL_SEGS"].data) self.gl_ssect = self._unpack_lump(SubSector, mapobj["GL_SSECT"].data) def to_lumps(self): m = NameGroup() m["_HEADER_"] = self.header m["VERTEXES"] = Lump(join([x.pack() for x in self.vertexes])) m["THINGS" ] = Lump(join([x.pack() for x in self.things ])) m["LINEDEFS"] = Lump(join([x.pack() for x in self.linedefs])) m["SIDEDEFS"] = Lump(join([x.pack() for x in self.sidedefs])) m["SECTORS" ] = Lump(join([x.pack() for x in self.sectors ])) m["NODES"] = Lump(join([x.pack() for x in self.nodes ])) m["SEGS"] = Lump(join([x.pack() for x in self.segs ])) m["SSECTORS"] = Lump(join([x.pack() for x in self.ssectors])) m["BLOCKMAP"] = self.blockmap m["REJECT"] = self.reject # hexen / zdoom script lumps try: m["BEHAVIOR"] = self.behavior m["SCRIPTS"] = self.scripts except AttributeError: pass return m def draw_sector(self, vertexes, sector=None, sidedef=None): """Draw a polygon from a list of vertexes. The vertexes may be either Vertex objects or simple (x, y) tuples. A sector object and prototype sidedef may be provided.""" assert len(vertexes) > 2 firstv = len(self.vertexes) firsts = len(self.sidedefs) if sector is None: sector = Sector() if sidedef is None: sidedef = Sidedef() self.sectors.append(copy(sector)) for i, v in enumerate(vertexes): if isinstance(v, tuple): x, y = v else: x, y = v.x, v.y self.vertexes.append(Vertex(x, y)) for i in range(len(vertexes)): side = copy(sidedef) side.sector = len(self.sectors)-1 self.sidedefs.append(side) #check if the new line is being written over an existing #and merge them if so. new_linedef = Linedef(vx_a=firstv+((i+1)%len(vertexes)), vx_b=firstv+i, front=firsts+i, flags=1) match_existing = False for lc in self.linedefs: if (self.compare_linedefs(new_linedef,lc) > 0): #remove midtexture and apply it to the upper/lower side.tx_low = self.sidedefs[lc.front].tx_mid side.tx_up = self.sidedefs[lc.front].tx_mid self.sidedefs[lc.front].tx_low = side.tx_mid self.sidedefs[lc.front].tx_up = side.tx_mid side.tx_mid = "-" self.sidedefs[lc.front].tx_mid = "-" lc.back = len(self.sidedefs)-1 match_existing = True lc.two_sided = True lc.impassable = False break if (match_existing == False): self.linedefs.append(new_linedef) def compare_vertex_positions(self,vertex1,vertex2): """Compares the positions of two vertices.""" if (vertex1.x == vertex2.x): if (vertex1.y == vertex2.y): return True return False def compare_linedefs(self,linedef1,linedef2): """Compare the vertex positions of two linedefs. Returns 0 for mismatch. Returns 1 when the vertex positions are the same. Returns 2 when the vertex positions are in the same order. Returns 3 when the linedefs use the same vertices, but flipped. Returns 4 when the linedefs use the exact same vertices. """ if (linedef1.vx_a == linedef2.vx_a): if (linedef1.vx_b == linedef2.vx_b): return 4 if (linedef1.vx_a == linedef2.vx_b): if (linedef1.vx_b == linedef2.vx_a): return 3 if (self.compare_vertex_positions(self.vertexes[linedef1.vx_a], self.vertexes[linedef2.vx_a])): if (self.compare_vertex_positions(self.vertexes[linedef1.vx_b], self.vertexes[linedef2.vx_b])): return 2 if (self.compare_vertex_positions(self.vertexes[linedef1.vx_a], self.vertexes[linedef2.vx_b])): if (self.compare_vertex_positions(self.vertexes[linedef1.vx_b], self.vertexes[linedef2.vx_a])): return 1 return 0 def compare_sectors(self,sect1,sect2): """Compare two sectors' data and returns True when they match.""" if (sect1.z_floor == sect2.z_floor and sect1.z_ceil == sect2.z_ceil and sect1.tx_floor == sect2.tx_floor and sect1.tx_ceil == sect2.tx_ceil and sect1.light == sect2.light and sect1.type == sect2.type and sect1.tag == sect2.tag): return True return False def combine_sectors(self,sector1,sector2,remove_linedefs=True): """Combines two sectors together, replacing all references to the second with the first. If remove_linedefs is true, any linedefs that connect the two sectors will be removed.""" for sd in self.sidedefs: if (self.sectors[sd.sector] == sector2): sd.sector = self.sectors.index(sector1) if (remove_linedefs): for lc in self.linedefs: if (lc.back != Linedef.NONE): if (self.sectors[self.sidedefs[lc.front].sector] == sector1 and self.sectors[self.sidedefs[lc.back].sector] == sector1): self.linedefs.remove(lc) # we can rely on a nodebuilder to remove unused sectors # self.sectors[self.sectors.index(sector2)].tx_floor = "_REMOVED" def paste(self, other, offset=(0,0)): """Insert content of another map.""" vlen = len(self.vertexes) ilen = len(self.sidedefs) slen = len(self.sectors) for vx in other.vertexes: x, y = vx.x, vx.y self.vertexes.append(Vertex(x+offset[0], y+offset[1])) for line in other.linedefs: z = copy(line) z.vx_a += vlen z.vx_b += vlen if z.front != Linedef.NONE: z.front += ilen if z.back != Linedef.NONE: z.back += ilen self.linedefs.append(z) for side in other.sidedefs: z = copy(side) z.sector += slen self.sidedefs.append(z) for sector in other.sectors: z = copy(sector) self.sectors.append(z) for thing in other.things: z = copy(thing) z.x += offset[0] z.y += offset[1] self.things.append(z) omgifol-0.5.1/omg/palette.py000066400000000000000000000235461443325014200157530ustar00rootroot00000000000000from struct import pack, unpack from omg.util import * class Palette: """Used for storing a list of colors and doing things with them (such as looking up the best match for arbitrary RGB values). Palette is distinct from Colormap and Playpal; these two provide lump- and WAD-related operations while delegating (some of) their color processing to Palette. Fields containing useful public data (modifying them directly probably isn't a good idea, however): .colors List of (r, g, b) tuples .bytes Palette's colors as a bytes object (rgbrgbrgb...) .save_bytes Same as above, but with the transparency color set; useful when saving files .tran_color (r, g, b) value for transparency .tran_index Index in palette of transparency color The following fields are intended for internal use: .memo Table for RGB lookup memoization .grays List of indices of colors with zero saturation .bright_lut Brightness LUT, used internally to speed up lookups (when not memoized). """ def __init__(self, colors=None, tran_index=None, tran_color=None): """Creates a new Palette object. The 'colors' argument may be either a list of (r,g,b) tuples or an RGBRGBRGB... string/bytes. 'tran_index' specifies the index in the palette where the transparent color should be placed. Note that this is only used when saving images, and thus doesn't affect color lookups. 'tran_color' is the color to use for transparency.""" colors = colors or default_colors tran_index = tran_index or default_tran_index tran_color = tran_color or default_tran_color if isinstance(colors, str): colors = colors.encode('latin-1') if isinstance(colors, list): self.colors = colors[:] elif isinstance(colors, bytes): self.colors = [unpack('BBB', colors[i:i+3]) for i in range(0,768,3)] else: raise TypeError("Argument 'colors' must be list or string or bytes") # Doom graphics don't actually use indices for transparency; the # following data is only used when converting between image formats. self.tran_index = tran_index self.tran_color = tran_color self.make_bytes() self.make_grays() # Memoizing color translations can speed up RGB-to-palette # conversions significantly, in particular when converting # lots of graphics in one session. See docstring for build_lut # below for description of what bright_lut does. self.memo = {} self.bright_lut = [] self.reset_memo() def make_bytes(self): """Create/update 'bytes' and 'save_bytes' from the current set of colors and the 'tran_index' and 'tran_color' fields.""" self.bytes = bytes().join([pack('BBB', *rgb) for rgb in self.colors]) self.save_bytes = \ self.bytes[:self.tran_index*3] + \ pack('BBB', *self.tran_color) + \ self.bytes[(self.tran_index+1)*3:] def make_grays(self): """Create 'grays' table containing the indices of all grays in the current set of colors.""" self.grays = [i for i, rgb in enumerate(self.colors) \ if (rgb[0]==rgb[1]==rgb[2])] def reset_memo(self): """Clear the memo table (but (re)add the palette's colors)""" self.memo = {} for i in range(len(self.colors)): if i != self.tran_index: self.memo[self.colors[i]] = i def build_lut(self, distance=16): """Build 256-entry LUT for looking up colors in the palette close to a given brightness (range 0-255). Each entry is a list of indices. No position is empty; in the worst case, the closest gray can be used. The 'distance' parameter defines what "close" is, and should be an integer 0-256. Lower distance means faster lookups, but worse precision. A good value for Doom is 10. Anything over 32 only wastes time. """ self.bright_lut = [] assert 0 <= distance <= 256 for level in range(256): candidates = [] for j, rgb in enumerate(self.colors): if abs(level - (sum(rgb) // 3)) < distance: candidates.append(j) # Make sure each entry contains at least one gray # color that can be relied on in the worst case best_d = 256 best_i = 0 for gray_index in self.grays: r, g, b = self.colors[gray_index] dist = abs(r - level) if dist < best_d: best_i = gray_index if dist == 0: break best_d = dist if best_i not in candidates: candidates.append(best_i) self.bright_lut.append(candidates) def match(self, color): """Find the closest match in the palette for a color. Takes an (r,g,b) tuple as argument and returns a palette index.""" if color == self.tran_color: return self.tran_index if color in self.memo: return self.memo[color] if len(self.bright_lut) == 0: self.build_lut() best_dist = 262144 best_i = 0 ar, ag, ab = color candidates = self.bright_lut[int(sum(color)) // 3] for i in candidates: br, bg, bb = self.colors[i] dr = ar-br dg = ag-bg db = ab-bb dist = dr*dr + dg*dg + db*db if dist < best_dist: if dist == 0: return i best_dist = dist best_i = i self.memo[color] = best_i return best_i def blend(self, color, intensity=0.5): """Blend the entire palette against a color (given as an RGB triple). Intensity must be a floating-point number in the range 0-1.""" assert 0.0 <= intensity <= 1.0 nr = color[0] * intensity ng = color[1] * intensity nb = color[2] * intensity remain = 1.0 - intensity for i in range(len(self.colors)): ar, ag, ab = self.colors[i] self.colors[i] = (int(ar*remain + nr), int(ag*remain + ng), int(ab*remain + nb)) self.make_bytes() self.make_grays() self.reset_memo() self.bright_lut = [] # Colors of the Doom palette, used by default default_colors = b"\ \x00\x00\x00\x1f\x17\x0b\x17\x0f\x07\x4b\x4b\x4b\xff\xff\xff\x1b\ \x1b\x1b\x13\x13\x13\x0b\x0b\x0b\x07\x07\x07\x2f\x37\x1f\x23\x2b\ \x0f\x17\x1f\x07\x0f\x17\x00\x4f\x3b\x2b\x47\x33\x23\x3f\x2b\x1b\ \xff\xb7\xb7\xf7\xab\xab\xf3\xa3\xa3\xeb\x97\x97\xe7\x8f\x8f\xdf\ \x87\x87\xdb\x7b\x7b\xd3\x73\x73\xcb\x6b\x6b\xc7\x63\x63\xbf\x5b\ \x5b\xbb\x57\x57\xb3\x4f\x4f\xaf\x47\x47\xa7\x3f\x3f\xa3\x3b\x3b\ \x9b\x33\x33\x97\x2f\x2f\x8f\x2b\x2b\x8b\x23\x23\x83\x1f\x1f\x7f\ \x1b\x1b\x77\x17\x17\x73\x13\x13\x6b\x0f\x0f\x67\x0b\x0b\x5f\x07\ \x07\x5b\x07\x07\x53\x07\x07\x4f\x00\x00\x47\x00\x00\x43\x00\x00\ \xff\xeb\xdf\xff\xe3\xd3\xff\xdb\xc7\xff\xd3\xbb\xff\xcf\xb3\xff\ \xc7\xa7\xff\xbf\x9b\xff\xbb\x93\xff\xb3\x83\xf7\xab\x7b\xef\xa3\ \x73\xe7\x9b\x6b\xdf\x93\x63\xd7\x8b\x5b\xcf\x83\x53\xcb\x7f\x4f\ \xbf\x7b\x4b\xb3\x73\x47\xab\x6f\x43\xa3\x6b\x3f\x9b\x63\x3b\x8f\ \x5f\x37\x87\x57\x33\x7f\x53\x2f\x77\x4f\x2b\x6b\x47\x27\x5f\x43\ \x23\x53\x3f\x1f\x4b\x37\x1b\x3f\x2f\x17\x33\x2b\x13\x2b\x23\x0f\ \xef\xef\xef\xe7\xe7\xe7\xdf\xdf\xdf\xdb\xdb\xdb\xd3\xd3\xd3\xcb\ \xcb\xcb\xc7\xc7\xc7\xbf\xbf\xbf\xb7\xb7\xb7\xb3\xb3\xb3\xab\xab\ \xab\xa7\xa7\xa7\x9f\x9f\x9f\x97\x97\x97\x93\x93\x93\x8b\x8b\x8b\ \x83\x83\x83\x7f\x7f\x7f\x77\x77\x77\x6f\x6f\x6f\x6b\x6b\x6b\x63\ \x63\x63\x5b\x5b\x5b\x57\x57\x57\x4f\x4f\x4f\x47\x47\x47\x43\x43\ \x43\x3b\x3b\x3b\x37\x37\x37\x2f\x2f\x2f\x27\x27\x27\x23\x23\x23\ \x77\xff\x6f\x6f\xef\x67\x67\xdf\x5f\x5f\xcf\x57\x5b\xbf\x4f\x53\ \xaf\x47\x4b\x9f\x3f\x43\x93\x37\x3f\x83\x2f\x37\x73\x2b\x2f\x63\ \x23\x27\x53\x1b\x1f\x43\x17\x17\x33\x0f\x13\x23\x0b\x0b\x17\x07\ \xbf\xa7\x8f\xb7\x9f\x87\xaf\x97\x7f\xa7\x8f\x77\x9f\x87\x6f\x9b\ \x7f\x6b\x93\x7b\x63\x8b\x73\x5b\x83\x6b\x57\x7b\x63\x4f\x77\x5f\ \x4b\x6f\x57\x43\x67\x53\x3f\x5f\x4b\x37\x57\x43\x33\x53\x3f\x2f\ \x9f\x83\x63\x8f\x77\x53\x83\x6b\x4b\x77\x5f\x3f\x67\x53\x33\x5b\ \x47\x2b\x4f\x3b\x23\x43\x33\x1b\x7b\x7f\x63\x6f\x73\x57\x67\x6b\ \x4f\x5b\x63\x47\x53\x57\x3b\x47\x4f\x33\x3f\x47\x2b\x37\x3f\x27\ \xff\xff\x73\xeb\xdb\x57\xd7\xbb\x43\xc3\x9b\x2f\xaf\x7b\x1f\x9b\ \x5b\x13\x87\x43\x07\x73\x2b\x00\xff\xff\xff\xff\xdb\xdb\xff\xbb\ \xbb\xff\x9b\x9b\xff\x7b\x7b\xff\x5f\x5f\xff\x3f\x3f\xff\x1f\x1f\ \xff\x00\x00\xef\x00\x00\xe3\x00\x00\xd7\x00\x00\xcb\x00\x00\xbf\ \x00\x00\xb3\x00\x00\xa7\x00\x00\x9b\x00\x00\x8b\x00\x00\x7f\x00\ \x00\x73\x00\x00\x67\x00\x00\x5b\x00\x00\x4f\x00\x00\x43\x00\x00\ \xe7\xe7\xff\xc7\xc7\xff\xab\xab\xff\x8f\x8f\xff\x73\x73\xff\x53\ \x53\xff\x37\x37\xff\x1b\x1b\xff\x00\x00\xff\x00\x00\xe3\x00\x00\ \xcb\x00\x00\xb3\x00\x00\x9b\x00\x00\x83\x00\x00\x6b\x00\x00\x53\ \xff\xff\xff\xff\xeb\xdb\xff\xd7\xbb\xff\xc7\x9b\xff\xb3\x7b\xff\ \xa3\x5b\xff\x8f\x3b\xff\x7f\x1b\xf3\x73\x17\xeb\x6f\x0f\xdf\x67\ \x0f\xd7\x5f\x0b\xcb\x57\x07\xc3\x4f\x00\xb7\x47\x00\xaf\x43\x00\ \xff\xff\xff\xff\xff\xd7\xff\xff\xb3\xff\xff\x8f\xff\xff\x6b\xff\ \xff\x47\xff\xff\x23\xff\xff\x00\xa7\x3f\x00\x9f\x37\x00\x93\x2f\ \x00\x87\x23\x00\x4f\x3b\x27\x43\x2f\x1b\x37\x23\x13\x2f\x1b\x0b\ \x00\x00\x53\x00\x00\x47\x00\x00\x3b\x00\x00\x2f\x00\x00\x23\x00\ \x00\x17\x00\x00\x0b\x00\x00\x00\xff\x9f\x43\xff\xe7\x4b\xff\x7b\ \xff\xff\x00\xff\xcf\x00\xcf\x9f\x00\x9b\x6f\x00\x6b\xa7\x6b\x6b\ " # Defaults for image transparency default_tran_index = 247 default_tran_color = (255, 0, 255) # Default palette object, using the default values default = Palette() omgifol-0.5.1/omg/playpal.py000066400000000000000000000052571443325014200157560ustar00rootroot00000000000000from omg.lump import Lump from omg.util import * import omg.palette class Playpal: """An editor for Doom's PLAYPAL lump. The PLAYPAL lump contains 14 palettes: the game's base palette, the palettes used when the player is in pain or uses the berserk powerup, the palettes used when the player picks up an item, and the palette used when the player is wearing the radiation suit. The palettes are located in a member list called 'palettes'. Each palette is a Palette instance.""" def __init__(self, source=None): """Construct a new EditPlaypal object. Source may be a PLAYPAL lump or a Palette instance. If a Palette instance, all 14 palettes are set to copies of it. If no source is specified, the default palette is used.""" if isinstance(source, Lump): self.from_lump(source) else: self.set_base(source) def build_defaults(self): """Build all 13 extra palettes, using default values (red for pain, yellow for item pickups, green for the radiation suit). The values used here are not exactly the same as those in Doom's PLAYPAL, but decent approximations.""" self.build_pain() self.build_item() self.build_suit() def build_suit (self, color=(0,255,0), intensity=0.2): """Set the color and intensity for the radiation suit palette.""" self.palettes[13].blend(color, intensity) def build_pain (self, color=(255,0,0), minintensity=0.1, maxintensity=0.8): """Set the color and intensities for the player-in-pain (also used by the berserk powerup) palettes.""" step = (maxintensity - minintensity) / 8.0 for i in range(8): self.palettes[i+1].blend(color, step*i + minintensity) def build_item (self, color=(255,255,64), minintensity=0.1, maxintensity=0.3): """Set color and intensity for the item pick-up palettes.""" step = (maxintensity - minintensity) / 3.0 for i in range(3): self.palettes[i+10].blend(color, step * i + minintensity) def from_lump(self, lump): """Load data from a PLAYPAL lump.""" self.palettes = [omg.palette.Palette(lump.data[i*768:(i+1)*768], 0, 0) for i in range(14)] def to_lump(self): """Compile to a Doom-ready PLAYPAL Lump.""" return Lump(join([p.bytes for p in self.palettes])) def set_base(self, palette=None): """Set all palettes to copies of a given Palette object. If the palette parameter is not provided, the default palette is used.""" palette = palette or omg.palette.default self.palettes = [deepcopy(palette) for i in range(14)] omgifol-0.5.1/omg/thinginfo.py000066400000000000000000000067231443325014200163000ustar00rootroot00000000000000# Map thing types... this needs to be expanded # Map descriptions to numbers and vice versa all_desc2num = {} all_num2desc = {} class ThingCategory: def __init__(self, table): global all_desc2num global all_num2desc rev = dict([(b, a) for a, b in table.items()]) all_desc2num.update(table) all_num2desc.update(rev) self.table = dict([(x, None) for x in table]) def __contains__(self, item): global all_desc2num global all_num2desc if isinstance(item, str): return item in self.table elif isinstance(item, int): return all_num2desc[item] in self.table else: raise TypeError monsters = ThingCategory({ "zombie":3004, "sergeant":9, "commando":65, "imp":3001, "demon":3002, "spectre":58, "lost soul":3006, "cacodemon":3005, "hell knight":69, "baron of hell":3003, "revenant":66, "mancubus":67, "arachnotron":68, "pain elemental":71, "archvile":64, "cyberdemon":16, "spider mastermind":7, "ss guy":84, "spawn target":87, "spawn shooter":89, "romero head":88, "commander keen":72 }) weapons = ThingCategory({ "shotgun":2001, "super shotgun":82, "chaingun":2002, "rocket launcher":2003, "plasma gun":2004, "chainsaw":2005, "bfg 9000":2006 }) ammo = ThingCategory({ "ammo clip":2007, "ammo box":2048, "shells":2008, "shell box":2049, "rocket":2010, "rocket box":2046, "cell charge":2047, "cell pack":17, "backpack":8 }) powerups = ThingCategory({ "stimpack":2011, "medikit":2012, "supercharge":2013, "health bonus":2014, "armor bonus":2015, "green armor":2018, "blue armor":2019, "invulnerability":2022, "berserk":2023, "invisibility":2024, "radiation suit":2025, "computer map":2026, "goggles":2048, "megasphere":83 }) keys = ThingCategory({ "red keycard":13, "yellow keycard":6, "blue keycard":5, "red skull key":38, "yellow skull key":39, "blue skull key":40 }) starts = ThingCategory({ "player 1 start":1, "player 2 start":2, "player 3 start":3, "player 4 start":4, "deathmatch start":11, "teleport destination":14 }) """ doom2_only = ThingCategory([ "super shotgun", "megasphere", "archvile", "chaingunner", "revenant", "mancubus", "arachnotron", "hell knight", "pain elemental", "ss guy", "spawn target", "spawn shooter", "romero head", "commander keen" "flaming barrel", ]) """ corpses = ThingCategory({ "gibs 1":10, "gibs 2":12, "dead marine":15, "dead zombie":18, "dead sergeant":19, "dead imp":20, "dead demon":21, "dead cacodemon":22, "dead lost soul":23, "pool of blood":24, "impaled human 1":25, "impaled human 2":26, "skull on pole":27, "five skulls":28, "skull pile":29, "hangman 1":49, "hangman 2":50, "hangman 3":51, "hangman 4":52, "hangman 5":53, "hangman 2 (passable)":59, "hangman 4 (passable)":60, "hangman 3 (passable)":61, "hangman 5 (passable)":62, "hangman 1 (passable)":63 }) decorations = ThingCategory({ "green pillar":30, "short green pillar":31, "red pillar":32, "short red pillar":33, "candle":34, "candelabra":35, "green pillar with heart":36, "red pillar with skull":37, "eye":41, "skull rock":42, "gray tree":43, "blue torch":44, "green torch":45, "red torch":46, "scrub":47, "tech column":48, "brown tree":54, "short blue torch":55, "short green torch":56, "short red torch":57, "floor lamp":2028, "barrel":2035 }) omgifol-0.5.1/omg/txdef.py000066400000000000000000000105141443325014200154160ustar00rootroot00000000000000from omg.lump import Lump from omg.util import * from omg.wad import TxdefGroup class TextureDef(WADStruct): """Class for texture definitions.""" _fields_ = [ ("name", ctypes.c_char * 8), ("dummy1", ctypes.c_uint32), ("width", ctypes.c_int16), ("height", ctypes.c_int16), ("dummy2", ctypes.c_uint32), ("npatches", ctypes.c_int16), ] def __init__(self, *args, **kwargs): self.name = "-" self.patches = [] super().__init__(*args, **kwargs) class PatchDef(WADStruct): """Class for patches.""" _fields_ = [ ("x", ctypes.c_int16), ("y", ctypes.c_int16), ("id", ctypes.c_int16), ("dummy1", ctypes.c_uint16), ("dummy2", ctypes.c_uint16) ] def __init__(self, *args, **kwargs): self.name = "-" self.id = -1 super().__init__(*args, **kwargs) # TODO: integrate with textures lump group instead? class Textures(OrderedDict): """An editor for Doom's TEXTURE1, TEXTURE2 and PNAMES lumps.""" def __init__(self, *args): """Create new, optionally loading content from given TEXTURE1/2 and PNAMES lumps or a txdefs group. E.g.: Textures(texture1, pnames) Textures(txdefs) """ OrderedDict.__init__(self) if len(args): self.from_lumps(*args) def from_lumps(self, *args): """Load texture definitions from a TEXTURE1/2 lump and its associated PNAMES lump, or a lump group containing the lumps.""" from omg.wad import LumpGroup if len(args) == 1: g = args[0] assert isinstance(g, LumpGroup) if "TEXTURE1" in g: self.from_lumps(g["TEXTURE1"], g["PNAMES"]) if "TEXTURE2" in g: self.from_lumps(g["TEXTURE2"], g["PNAMES"]) elif len(args) == 2: self._from_lumps(args[0], args[1]) def _from_lumps(self, texture1, pnames): # Unpack PNAMES numdefs = unpack16(pnames.data[0:2]) pnames = [zstrip(pnames.data[ptr:ptr+8]) \ for ptr in range(4, 8*numdefs+4, 8)] # Unpack TEXTURE1 data = texture1.data numtextures = unpack('> 10) if trigger < len(ULinedef.triggers_hexen): setattr(block, ULinedef.triggers_hexen[trigger], True) # handle line specials that set a line ID, extended flags, etc. based on the zdoom udmf spec if linedef.action == 1: # Polyobj_StartLine block.id = linedef.arg3 block.arg3 = 0 elif linedef.action == 5: # Polyobj_ExplicitLine block.id = linedef.arg4 block.arg4 = 0 elif linedef.action == 160: # Sector_Set3dFloor if linedef.arg1 & 8: block.id = linedef.arg4 block.arg1 &= ~8 else: block.arg0 += linedef.arg4 * 256 block.arg4 = 0 elif linedef.action == 181: # Plane_Align block.id = linedef.arg2 block.arg2 = 0 elif linedef.action == 208: # TranslucentLine block.id = linedef.arg0 # preserve arg0 according to spec for f in range(len(ULinedef.moreflags_hexen)): setattr(block, ULinedef.moreflags_hexen[f], bool(linedef.arg3 & (1 << f))) block.arg3 = 0 elif linedef.action == 215: # Teleport_Line block.id = linedef.arg0 block.arg0 = 0 elif linedef.action == 222: # Scroll_Texture_Model block.id = linedef.arg0 # preserve arg0 according to spec else: block.special = linedef.action block.id = block.arg0 = linedef.tag for sidedef in m.sidedefs: block = USidedef(sidedef.sector) self.sidedefs.append(block) block.offsetx = sidedef.off_x block.offsety = sidedef.off_y block.texturetop = sidedef.tx_up block.texturebottom = sidedef.tx_low block.texturemiddle = sidedef.tx_mid for vertex in m.vertexes: block = UVertex(float(vertex.x), float(vertex.y)) self.vertexes.append(block) for sector in m.sectors: block = USector(sector.tx_floor, sector.tx_ceil) self.sectors.append(block) block.heightfloor = sector.z_floor block.heightceiling = sector.z_ceil block.lightlevel = sector.light block.special = sector.type block.id = sector.tag def to_lumps(self): m = NameGroup() m['_HEADER_'] = Lump() m['TEXTMAP'] = Lump(str.encode(self.to_textmap())) if self.behavior: m['BEHAVIOR'] = self.behavior if self.scripts: m['SCRIPTS'] = self.scripts m['ENDMAP'] = Lump() return m def to_textmap(self): fallback = lambda *args: self.serialize_field(*args) out = 'namespace="{0}";\n'.format(self.namespace) for thing in self.things: out += 'thing ' + thing.to_textmap(fallback) for vertex in self.vertexes: out += 'vertex ' + vertex.to_textmap(fallback) for sidedef in self.sidedefs: out += 'sidedef ' + sidedef.to_textmap(fallback) for linedef in self.linedefs: out += 'linedef ' + linedef.to_textmap(fallback) for sector in self.sectors: out += 'sector ' + sector.to_textmap(fallback) return out def serialize_field(self, value): if isinstance(value, UVertex): return self.vertexes.index(value) if isinstance(value, USidedef): return self.sidedefs.index(value) if isinstance(value, ULinedef): return self.linedefs.index(value) if isinstance(value, USector): return self.sectors.index(value) if isinstance(value, UThing): return self.things.index(value) omgifol-0.5.1/omg/util.py000066400000000000000000000143671443325014200152730ustar00rootroot00000000000000""" Utilities -- common functions and classes, used variously by other Omgifol modules. """ from __future__ import print_function from fnmatch import fnmatchcase as wccmp, filter as wcinlist from struct import pack, unpack from copy import copy, deepcopy from collections import OrderedDict as od import ctypes class OrderedDict(od): """ Like collections.OrderedDict but with a few helpful extras: - dicts can be added together - find and rename methods - items(), keys(), and values() return normal lists """ def __init__(self, source=None): od.__init__(self) if source: self.update(source) def __add__(self, other): c = self.__class__(self) c.update(other) return c def __iadd__(self, other): self.update(other) return self def items(self): # return list instead of odict_items return [i for i in od.items(self)] def keys(self): # return list instead of odict_keys return [i for i in od.keys(self)] def values(self): # return list instead of odict_values return [i for i in od.values(self)] def find(self, pattern): """Find all items that match the given pattern (supporting wildcards). Returns a list of keys.""" return [k for k in od.keys(self) if wccmp(k, pattern)] def rename(self, old, new): """Rename an entry""" self[new] = self[old] del self[old] #---------------------------------------------------------------------- # # Miscellaneous convenient function # def join(seq): """Create a joined string out of a list of substrings.""" return bytes().join(seq) def readfile(source): """Read data from a file, return data as bytes. Target may be a path name string or a file-like object (with a `read` method).""" if isinstance(source, str): return open(source, 'rb').read() else: return source.read() def writefile(target, data): """Write data to a file. Target may be a path name string or a file-like object (with a `write` method).""" if isinstance(target, str): open(target,'wb').write(data) else: target.write(data) def any(set): for e in set: if e: return True return False def all(set): for e in set: if not e: return False return True def inwclist(elem, seq): return any(wccmp(elem, x) for x in seq) #---------------------------------------------------------------------- # # Functions for processing lump names and other strings # # Table for translating characters to those safe for use _trans_table = ["_"] * 256 for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[]\\_-": _trans_table[ord(c.lower())] = c _trans_table[ord(c)] = c _trans_table[0] = "\0" _trans_table = "".join(_trans_table) def zpad(chars): """Pad a string with zero bytes, up until a length of 8. The string is truncated if longer than 8 bytes.""" return pack('8s', chars.encode('ascii')) def zstrip(chars): """Return a string representing chars with all trailing null bytes removed. chars can be a string or byte string.""" if isinstance(chars, bytes): chars = str(chars.decode('ascii', 'ignore')) if '\0' in chars: return chars[:chars.index("\0")] return chars def safe_name(chars): return str(chars)[:8].translate(_trans_table) def fixname(chars): return zstrip(chars).translate(_trans_table) def fix_saving_name(name): """Neutralizes backslashes in Arch-Vile frame names.""" return name.rstrip('\0').replace('\\', '`') def fix_loading_name(name): """Restores backslash to Arch-Vile frame names.""" return fixname(name).replace('`', '\\') def unpack16(s): """Convert a packed signed short (2 bytes) to a Python int.""" return unpack('.tmp until the operation has finished, to stay safe in case of failure.""" use_backup = os.path.exists(filename) tmpfilename = filename + ".tmp" if use_backup: if os.path.exists(tmpfilename): os.remove(tmpfilename) os.rename(filename, tmpfilename) w = WadIO(filename) for group in write_order: self.__dict__[group].save_wadio(w, use_free=False) w.save() if use_backup: os.remove(tmpfilename) def __add__(self, other): assert isinstance(other, WAD) w = WAD(structure=self.structure) for group_def in self.structure: name = group_def[1] w.__dict__[name] = self.__dict__[name] + other.__dict__[name] return w def copy(self): return deepcopy(self) omgifol-0.5.1/omg/wadio.py000066400000000000000000000311051443325014200154060ustar00rootroot00000000000000import os, hashlib, time from omg.util import * class Header(WADStruct): """Class for WAD file headers.""" _fields_ = [ ("type", ctypes.c_char * 4), ("dir_len", ctypes.c_uint32), ("dir_ptr", ctypes.c_uint32) ] def __init__(self, *args, **kwargs): self.type = "PWAD" super().__init__(*args, **kwargs) class Entry(WADStruct): """Class for WAD file entries.""" _fields_ = [ ("ptr", ctypes.c_uint32), ("size", ctypes.c_uint32), ("name", ctypes.c_char * 8), ] def __init__(self, *args, **kwargs): self.been_read = False # Used by WAD loader super().__init__(*args, **kwargs) # WadIO.open() behaves just like open(). Sometimes it is # useful to specifically either open an existing file # or create a new one. def open_wad(): """Open an existing WAD, raise IOError if not found.""" if not os.path.exists(location): raise IOError return WadIO(location) def create_wad(location): """Create a new WAD, raise IOError if exists.""" if os.path.exists(location): raise IOError return WadIO(location) class WadIO: """A WadIO object is used to open a WAD file for direct reading and writing. IMPORTANT: In case the contents of the file have been modified, .save() must be called before exiting, or data will be lost/the universe explodes. You can check whether the file is in need of saving by reading the value of the boolean attribute .issafe WadIO objects work on the WAD directory level. There is very little magic available - you can't do things like automatic merging or managing sections, and Omgifol is never aware of what types lumps are. In other words, you're doing things more or less manually, with the hard binary content of lumps and the linear order they're stored in. Use this for very large WADs and when only performing small/few operations. For example, when you need to read a lump or two from an IWAD for copying into another WAD. Also use it when it is important that the saved file is identical to the opened file; that is not guaranteed to work with the higher-level WAD class since it sometimes modifies order. The benefit of using this class is that you don't have to read and write the whole file when opening or closing. The downside is that changes can't be undone (so back up first!) and that file content will get fragmented when you edit lumps (unused space will appear). To get rid of the wasted space, use the rewrite() method (which rewrites the entire file).""" def __init__(self, openfrom=None): self.basefile = None self.issafe = True self.header = Header() self.entries = [] if openfrom is not None: self.open(openfrom) def __del__(self): if self.basefile: self.basefile.close() def open(self, filename): """Open a WAD file, create a new file if none exists at the path.""" assert not self.entries if self.basefile: raise IOError("The handle is already open") # Open an existing WAD if os.path.exists(filename): try: self.basefile = open(filename, 'r+b') except IOError: # assume file is read-only self.basefile = open(filename, 'rb') filesize = os.stat(self.basefile.name)[6] self.header = h = Header(bytes=self.basefile.read(ctypes.sizeof(Header))) if (not h.type in ("PWAD", "IWAD")) or filesize < 12: raise IOError("The file is not a valid WAD file.") if filesize < h.dir_ptr + h.dir_len*ctypes.sizeof(Entry): raise IOError("Invalid directory information in header.") self.basefile.seek(h.dir_ptr) self.entries = [Entry(bytes=self.basefile.read(ctypes.sizeof(Entry))) \ for i in range(h.dir_len)] # Create new else: self.basefile = open(filename, 'w+b') self.basefile.write(Header().pack()) self.basefile.flush() def close(self): """Close the base file.""" assert self.basefile # Unfortunately, a save can't be forced here. if not self.issafe: raise IOError(\ "closing a modified file may corrupt it. use save() first") self.basefile.close() self.basefile = None def select(self, id): """Return a valid index from a proposed index or entry name, or raise LookupError in case of failure.""" assert self.basefile if isinstance(id, int): if id < len(self.entries): return id raise LookupError elif isinstance(id, str): for i in range(len(self.entries)): if wccmp(self.entries[i].name, id): return i raise LookupError raise TypeError def get(self, id): return self.entries[self.select(id)] def find(self, id): """Search for an entry and return the index of the first match or None if no matches were found. Wildcards are supported.""" assert self.basefile try: return self.select(id) except LookupError: return None def multifind(self, id, start=None, end=None): """Search for entries and return a list of matches. Wildcards are supported.""" assert self.basefile if start is None: start = 0 if end is None: end = len(self.entries) return [i for i in range(start, end) if \ wccmp(self.entries[i].name, id)] def read(self, id): """Read an entry and return the data as a binary string.""" assert self.basefile id = self.select(id) self.basefile.seek(self.entries[id].ptr) return self.basefile.read(self.entries[id].size) def remove(self, id): """Remove an entry.""" assert self.basefile del (self.entries[self.select(id)]) self.issafe = False def rename(self, id, new): """Rename an entry.""" assert self.basefile self.entries[self.select(id)].name = new[0:8].upper() self.issafe = False def write_at(self, pos, data): """Write data at the given position.""" self.basefile.seek(pos) self.basefile.write(data) def write_append(self, data): """Write data at the end of the file.""" self.basefile.seek(0, 2) self.basefile.write(data) def write_free(self, data): """Write data to empty space in the file, if available, otherwise write to the end of the file. Returns the position that was written to. """ self.basefile.seek(0, 2) pos = self.basefile.tell() # Find the earliest available free space # (or, if free space reaches to the end of the file, use it) for p in self.calc_waste()[1]: if p[1] - p[0] >= len(data) or p[1] == pos: pos = p[0] self.basefile.seek(pos) break self.basefile.write(data) return pos def insert(self, name, data, index=None, use_free=True): """Insert a new entry at the optional index (defaults to appending). If use_free is true, existing free space in the WAD will be used, if possible.""" assert self.basefile try: index = self.select(index) except: index = None self.issafe = False if len(data) == 0: pos = 0 elif use_free: # write data to end of file or use free space if possible pos = self.write_free(data) else: self.basefile.seek(0, 2) pos = self.basefile.tell() self.basefile.write(data) if index is None: self.entries.append(Entry(pos, len(data), name)) else: self.entries.insert(index, Entry(pos, len(data), name)) self.basefile.flush() def update(self, id, data): """Write new data for an existing lump. If the new data is bigger than what's present, a new position in the file will be allocated for the lump.""" assert self.basefile id = self.select(id) if len(data) != self.entries[id].size: self.issafe = False if len(data) == 0: self.entries[i].ptr = 0 elif len(data) <= self.entries[id].size: self.write_at(self.entries[id].ptr, data) else: # temporarily mark existing entry as free, its current space will # be combined with any adjacent free space self.entries[id].size = 0 # write data to end of file or use free space if possible self.entries[id].ptr = self.write_free(data) self.entries[id].size = len(data) self.basefile.flush() def save(self): """Save directory and header changes to the WAD file.""" assert self.basefile if self.issafe: return dir = join([e.pack() for e in self.entries]) self.header.dir_len = len(self.entries) self.header.dir_ptr = self.write_free(dir) self.write_at(0, self.header.pack()) self.basefile.flush() self.issafe = True def rewrite(self): """Rewrite the entire WAD file. This removes all garbage (wasted space) from the file.""" assert self.basefile fpath = self.basefile.name # Write to a temporary file and rename it when done # os.tmpnam works too, but gives a security warning tmppath = hashlib.md5(str(time.time())).hexdigest()[:8] + ".tmp" tmppath = os.path.join(os.path.dirname(fpath), tmppath) outwad = create_wad(tmppath) for i in range(len(self.entries)): outwad.insert(self.entries[i].name, self.read(i), use_free=False) outwad.save() outwad.close() self.close() os.remove(fpath) os.rename(tmppath, fpath) self.entries = [] self.open(fpath) self.issafe = True def calc_waste(self): """Returns an (int, list) tuple containing the total amount of wasted space in the WAD and a list of (start, end) tuples for the spots where the wasted chunks are located.""" assert self.basefile filesize = os.stat(self.basefile.name)[6] # Create a list of (start, end) tuples to represent used space chunks = [] # Treat the header and the end of the file as chunks of used space chunks.append((0, 12)) chunks.append((filesize, filesize + 1)) # Add to the list the chunks that are occupied by lump data chunks.append((self.header.dir_ptr, self.header.dir_ptr + \ len(self.entries)*ctypes.sizeof(Entry))) for entry in self.entries: if entry.size > 0: chunks.append((entry.ptr, entry.ptr + entry.size)) # Sort it so we can go through it linearly and check for gaps chunks.sort() positions = [] space = 0 for i in range(0, len(chunks)-1): # Check whether the end of the chunk touches the beginning of the # next one. If not, there's wasted space between them. if chunks[i][1] < chunks[i+1][0]: positions.append((chunks[i][1], chunks[i+1][0])) space += positions[-1][1] - positions[-1][0] return space, positions def info_text(self): """Return printable fancy-formatted info about the WAD file.""" assert self.basefile assert self.issafe filesize = os.stat(self.basefile.name)[6] s = [] # Main information s.append("Info for %s\n\n" % self.basefile.name) s.append("Type: %s\n" % self.header.type) s.append("Size: %d bytes\n" % filesize) s.append("Directory start: 0x%x" % self.header.dir_ptr) # List all the lumps and some relevant information s.append("\n\nEntries:\n\n # Name Size Position\n\n") for i, entry in enumerate(self.entries): s.append("%6i %s %s 0x%x\n" % (i, entry.name.ljust(10), str(entry.size).ljust(10), entry.ptr)) # Add info about wasted space in the WAD, to find out how much of an # improvement a rewrite() would make total, wasted = self.calc_waste() s.append("\nWasted space:\n\n") s.append(" %s bytes total\n" % str(total)) for w in wasted: s.append(" %i bytes starting at 0x%x\n" % (w[1]-w[0], w[0])) return ''.join(s) omgifol-0.5.1/readme.txt000066400000000000000000000015011443325014200151420ustar00rootroot00000000000000Omgifol -- a Python library for Doom WAD files Originally by Fredrik Johansson (http://fredrikj.net). Maintained since 0.3.0 by Devin Acker (http://revenant1.net). Use `pip install omgifol` to install. See manual.html (and module/class docstrings) for usage notes. Requires Python 3.x. Some planned things: - Basic Doom 0.4 / 0.5 wad support in master - Basic Doom 64 wad support - support for non-vanilla/Boom maps in lineinfo - some stuff from AlexMax's fork The "doomalphas" branch contains extremely rudimentary loading of maps from the Doom 0.4 / 0.5 alphas. It was used to generate linedef animations for the "dmvis" project and is pretty much completely useless for anything else (it only loads linedefs and things, not sectors or texture/flat info). The struct info was gleaned from the Yadex source code (thanks!) omgifol-0.5.1/setup.cfg000066400000000000000000000000161443325014200147650ustar00rootroot00000000000000[bdist_wheel] omgifol-0.5.1/setup.py000066400000000000000000000011741443325014200146640ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup, find_packages import glob setup( name = 'omgifol', version = '0.5.1', description = 'A Python library for manipulation of Doom WAD files', url = 'https://github.com/devinacker/omgifol', author = 'Devin Acker, Fredrik Johansson', author_email = 'd@revenant1.net', license = 'MIT', classifiers = [ 'Development Status :: 3 - Alpha', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', 'Operating System :: OS Independent', ], python_requires = ">=3.3", packages = find_packages(exclude = ['demo']), scripts = glob.glob("demo/*.py"), )