From bb7998d1f040e7bba88fc0db9968134939790ed2 Mon Sep 17 00:00:00 2001
From: Andy McFadden <fadden@fadden.com>
Date: Mon, 29 Jun 2020 18:29:18 -0700
Subject: [PATCH] Progress toward OMF file handling

Added generation of data and project files.  We're applying the
relocation dictionary, but not using the information to inform the
formatting.
---
 SourceGen/DisasmProject.cs                   |   3 +-
 SourceGen/Res/Strings.xaml                   |   2 +
 SourceGen/Res/Strings.xaml.cs                |   4 +
 SourceGen/SourceGen.csproj                   |   1 +
 SourceGen/SystemDefs.cs                      |  15 +-
 SourceGen/Tools/Omf/Loader.cs                | 389 +++++++++++++++++++
 SourceGen/Tools/Omf/OmfFile.cs               |   1 +
 SourceGen/Tools/Omf/OmfSegment.cs            |  25 ++
 SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml    |   6 +-
 SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml.cs |  46 +++
 10 files changed, 487 insertions(+), 5 deletions(-)
 create mode 100644 SourceGen/Tools/Omf/Loader.cs

diff --git a/SourceGen/DisasmProject.cs b/SourceGen/DisasmProject.cs
index a2b9d06..2d79ade 100644
--- a/SourceGen/DisasmProject.cs
+++ b/SourceGen/DisasmProject.cs
@@ -288,7 +288,8 @@ namespace SourceGen {
         /// Prepares the DisasmProject for use as a new project.
         /// </summary>
         /// <param name="fileData">65xx data file contents.</param>
-        /// <param name="dataFileName">Data file's filename (not pathname).</param>
+        /// <param name="dataFileName">Data file's filename (not pathname).  Only used for
+        ///   cosmetic stuff, e.g. exporting to text; not stored in project.</param>
         public void PrepForNew(byte[] fileData, string dataFileName) {
             Debug.Assert(fileData.Length == FileDataLength);
 
diff --git a/SourceGen/Res/Strings.xaml b/SourceGen/Res/Strings.xaml
index c28d9ba..8b24a58 100644
--- a/SourceGen/Res/Strings.xaml
+++ b/SourceGen/Res/Strings.xaml
@@ -130,6 +130,7 @@ limitations under the License.
     <system:String x:Key="str_MsgVisualizationIgnored">Visualization ignored</system:String>
     <system:String x:Key="str_NoFilesAvailable">no files available</system:String>
     <system:String x:Key="str_NoExportedSymbolsFound">No exported symbols found.</system:String>
+    <system:String x:Key="str_OmfSegCommentFmt" xml:space="preserve">&#x0d;Segment {0}: Kind={2}, SegName='{1}'&#x0d;&#x0d;</system:String>
     <system:String x:Key="str_OpenDataDoesntExist">The file doesn't exist.</system:String>
     <system:String x:Key="str_OpenDataEmpty">File is empty</system:String>
     <system:String x:Key="str_OpenDataFailCaption">Unable to load data file</system:String>
@@ -141,6 +142,7 @@ limitations under the License.
     <system:String x:Key="str_OpenDataWrongLengthFmt">The file is {0:N0} bytes long, but the project expected {1:N0}.</system:String>
     <system:String x:Key="str_OpenDataWrongCrcFmt">The file has CRC {0}, but the project expected {1}.</system:String>
     <system:String x:Key="str_OperationFailed">Failed</system:String>
+    <system:String x:Key="str_OperationSucceeded">Success!</system:String>
     <system:String x:Key="str_ParentheticalNone">(none)</system:String>
     <system:String x:Key="str_PluginDirFailFmt">Failed while preparing the plugin directory {0}</system:String>
     <system:String x:Key="str_PluginDirFailCaption">Failed Preparing Plugin Directory</system:String>
diff --git a/SourceGen/Res/Strings.xaml.cs b/SourceGen/Res/Strings.xaml.cs
index 86fd659..8a76d76 100644
--- a/SourceGen/Res/Strings.xaml.cs
+++ b/SourceGen/Res/Strings.xaml.cs
@@ -241,6 +241,8 @@ namespace SourceGen.Res {
             (string)Application.Current.FindResource("str_NoFilesAvailable");
         public static string NO_EXPORTED_SYMBOLS_FOUND =
             (string)Application.Current.FindResource("str_NoExportedSymbolsFound");
+        public static string OMF_SEG_COMMENT_FMT =
+            (string)Application.Current.FindResource("str_OmfSegCommentFmt");
         public static string OPEN_DATA_DOESNT_EXIST =
             (string)Application.Current.FindResource("str_OpenDataDoesntExist");
         public static string OPEN_DATA_EMPTY =
@@ -263,6 +265,8 @@ namespace SourceGen.Res {
             (string)Application.Current.FindResource("str_OpenDataWrongLengthFmt");
         public static string OPERATION_FAILED =
             (string)Application.Current.FindResource("str_OperationFailed");
+        public static string OPERATION_SUCCEEDED =
+            (string)Application.Current.FindResource("str_OperationSucceeded");
         public static string PARENTHETICAL_NONE =
             (string)Application.Current.FindResource("str_ParentheticalNone");
         public static string PLUGIN_DIR_FAIL_FMT =
diff --git a/SourceGen/SourceGen.csproj b/SourceGen/SourceGen.csproj
index 0d668b6..1d342fc 100644
--- a/SourceGen/SourceGen.csproj
+++ b/SourceGen/SourceGen.csproj
@@ -85,6 +85,7 @@
       <DependentUpon>GenTestRunner.xaml</DependentUpon>
     </Compile>
     <Compile Include="Tools\ApplesoftToHtml.cs" />
+    <Compile Include="Tools\Omf\Loader.cs" />
     <Compile Include="Tools\Omf\OmfFile.cs" />
     <Compile Include="Tools\Omf\OmfRecord.cs" />
     <Compile Include="Tools\Omf\OmfReloc.cs" />
diff --git a/SourceGen/SystemDefs.cs b/SourceGen/SystemDefs.cs
index 8c79bd0..2431186 100644
--- a/SourceGen/SystemDefs.cs
+++ b/SourceGen/SystemDefs.cs
@@ -204,10 +204,19 @@ namespace SourceGen {
                     "': Unexpected contents '" + sdf.Contents + "'");
             }
 
-            foreach (SystemDef sd in sdf.Defs) {
-                Debug.WriteLine("### " + sd);
-            }
+            //foreach (SystemDef sd in sdf.Defs) {
+            //    Debug.WriteLine("### " + sd);
+            //}
             return sdf;
         }
+
+        public SystemDef FindEntryByName(string name) {
+            foreach (SystemDef sd in Defs) {
+                if (sd.Name == name) {
+                    return sd;
+                }
+            }
+            return null;
+        }
     }
 }
diff --git a/SourceGen/Tools/Omf/Loader.cs b/SourceGen/Tools/Omf/Loader.cs
new file mode 100644
index 0000000..0bcb762
--- /dev/null
+++ b/SourceGen/Tools/Omf/Loader.cs
@@ -0,0 +1,389 @@
+/*
+ * Copyright 2020 faddenSoft
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+
+using CommonUtil;
+
+namespace SourceGen.Tools.Omf {
+    /// <summary>
+    /// Apple IIgs OMF loader.  This works like the GS/OS System Loader, reading the contents
+    /// of an executable file and resolving the relocation records.  This only handles Load
+    /// files, as Object and Library files contain unresolved references.
+    /// </summary>
+    public class Loader {
+        private const string IIGS_SYSTEM_DEF = "Apple IIgs (GS/OS)";
+
+        private OmfFile mOmfFile;
+
+        private byte[] mLoadedData;
+        private DisasmProject mNewProject;
+
+        private class SegmentMapEntry {
+            public OmfSegment Segment { get; private set; }
+            public int Address { get; private set; }
+
+            public SegmentMapEntry(OmfSegment omfSeg, int address) {
+                Segment = omfSeg;
+                Address = address;
+            }
+        }
+        private List<SegmentMapEntry> mSegmentMap;
+
+
+        /// <summary>
+        /// Constructor.
+        /// </summary>
+        /// <param name="omfFile">OMF file to load.</param>
+        public Loader(OmfFile omfFile) {
+            Debug.Assert(omfFile.OmfFileKind == OmfFile.FileKind.Load);
+
+            mOmfFile = omfFile;
+        }
+
+        /// <summary>
+        /// Prepares the loaded form of the binary and the disassembly project.
+        /// </summary>
+        public bool Prepare() {
+            if (!CreateMap()) {
+                mSegmentMap = null;
+                return false;
+            }
+
+            Debug.WriteLine("Segment map:");
+            for (int i = 0; i < mSegmentMap.Count; i++) {
+                SegmentMapEntry ent = mSegmentMap[i];
+                if (ent == null) {
+                    Debug.Assert(i == 0 || i == 1);     // initial hole and optional ~ExpressLoad
+                    continue;
+                }
+                OmfSegment omfSeg = ent.Segment;
+                Debug.WriteLine(i + " " + ent.Address.ToString("x6") + " SegNum=" + omfSeg.SegNum +
+                    " '" + omfSeg.SegName + "'");
+
+                Debug.Assert(i == ent.Segment.SegNum);
+            }
+
+            if (!GenerateOutput()) {
+                mSegmentMap = null;
+                return false;
+            }
+
+            return true;
+        }
+
+        /// <summary>
+        /// Writes the data and disasm project files.
+        /// </summary>
+        /// <param name="dataPathName"></param>
+        /// <param name="projectPathName"></param>
+        public bool WriteProjectFiles(string dataPathName, string projectPathName,
+                out string errMsg) {
+            Debug.WriteLine("Writing " + dataPathName + " and " + projectPathName);
+
+            using (FileStream fs = new FileStream(dataPathName, FileMode.Create)) {
+                fs.Write(mLoadedData, 0, mLoadedData.Length);
+            }
+
+            if (!ProjectFile.SerializeToFile(mNewProject, projectPathName, out errMsg)) {
+                return false;
+            }
+
+            return true;
+        }
+
+        /// <summary>
+        /// Creates a map of file segments.  The position of each segment in the list will
+        /// match the segment's position in the file, i.e. the segment in Map[5] will have
+        /// SEGNUM==5.
+        /// </summary>
+        /// <remarks>
+        /// I'm assuming that the SEGNUM in the file matches the position.  This seems to be
+        /// the case everywhere.  ExpressLoad goes to some lengths to ensure this is still the
+        /// case after a file is "expressed", including a remap table so that loader calls made
+        /// by the application go to the right place (instead of, say, giving ~ExpressLoad a
+        /// SEGNUM of 255.)
+        /// </remarks>
+        /// <returns>True on success.</returns>
+        private bool CreateMap() {
+            // Segments are numbered 1-N, so create a map with N+1 entries and leave first blank.
+            mSegmentMap = new List<SegmentMapEntry>(mOmfFile.SegmentList.Count + 1);
+            mSegmentMap.Add(null);
+
+            // Create a bank in-use map.
+            bool[] inUse = new bool[256];
+
+            // Flag special memory as in-use.
+            inUse[0x00] = inUse[0x01] = inUse[0xe0] = inUse[0xe1] = true;
+
+            // Find segments that require specific addresses, and mark those banks as in use.
+            foreach (OmfSegment omfSeg in mOmfFile.SegmentList) {
+                if (omfSeg.Kind == OmfSegment.SegmentKind.DpStack) {
+                    // This just allocates space in bank 0.
+                    continue;
+                }
+                if (omfSeg.Length == 0) {
+                    // Nothing to do here.
+                    continue;
+                }
+
+                int addr;
+
+                if (omfSeg.Org == 0) {
+                    // The docs say that a value of zero always means relocatable, but that
+                    // would mean you can't set the "absolute bank" flag to position code or
+                    // data in bank 0.  I'm going to assume that's intentional, since people
+                    // (a) shouldn't be doing that, and (b) can use DP/Stack instead (?).
+                    continue;
+                }
+
+                addr = omfSeg.Org;
+                if ((omfSeg.Attrs & OmfSegment.SegmentAttribute.AbsBank) != 0) {
+                    // Bank is specified, rest of address is not.
+                    addr &= 0x00ff0000;
+                }
+
+                // Mark the banks as being in use.  It's okay if multiple segments want the
+                // same space.
+                MarkBanks(addr, omfSeg.Length, inUse);
+            }
+
+            //
+            // Assign segments to banks.  Note we always start at offset $0000 within a bank.
+            //
+
+            int nextBank = 0;
+            int dpAddr = 0x1000;    // somewhat arbitrary
+            foreach (OmfSegment omfSeg in mOmfFile.SegmentList) {
+                if (omfSeg.Kind == OmfSegment.SegmentKind.DpStack || omfSeg.Length == 0) {
+                    mSegmentMap.Add(new SegmentMapEntry(omfSeg, dpAddr));
+                    dpAddr += omfSeg.Length;
+                    if (dpAddr > 0x00010000) {
+                        Debug.WriteLine("Stack/DP overflow");
+                        return false;
+                    }
+                    continue;
+                }
+                if (omfSeg.IsExpressLoad) {
+                    // We totally ignore these.  Add a null ref as a placeholder.
+                    mSegmentMap.Add(null);
+                    continue;
+                }
+
+                int addr;
+
+                if (omfSeg.Org != 0) {
+                    // Specific address requested.
+                    addr = omfSeg.Org;
+                    if ((omfSeg.Attrs & OmfSegment.SegmentAttribute.AbsBank) != 0) {
+                        // just keep the bank
+                        addr &= 0x00ff0000;
+                    }
+                } else {
+                    // Find next available spot with enough space.
+                    while (true) {
+                        while (nextBank < 256 && inUse[nextBank]) {
+                            nextBank++;
+                        }
+                        if (nextBank == 256) {
+                            // Should be impossible on any sane Apple IIgs Load file.
+                            Debug.Assert(false);
+                            return false;
+                        }
+                        if (!CheckBanks(nextBank << 16, omfSeg.Length, inUse)) {
+                            // Didn't fit in the space.
+                            nextBank++;
+                            continue;
+                        }
+
+                        // We only go forward, so no need to mark them.
+
+                        break;
+                    }
+
+                    addr = nextBank << 16;
+
+                    // Advance nextBank.  We do this by identifying the last address touched,
+                    // and moving to the next bank.
+                    int lastAddr = addr + omfSeg.Length - 1;
+                    nextBank = (lastAddr >> 16) + 1;
+                }
+
+                SegmentMapEntry ent = new SegmentMapEntry(omfSeg, addr);
+                mSegmentMap.Add(ent);
+            }
+
+            return true;
+        }
+
+        private static bool CheckBanks(int addr, int memLen, bool[] inUse) {
+            Debug.Assert(memLen > 0);
+            while (memLen > 0) {
+                if (inUse[(addr >> 16) & 0xff]) {
+                    return false;
+                }
+                addr += 65536;
+                memLen -= 65536;
+            }
+            return true;
+
+        }
+
+        private static bool MarkBanks(int addr, int memLen, bool[] inUse) {
+            Debug.Assert(memLen > 0);
+            while (memLen > 0) {
+                inUse[(addr >> 16) & 0xff] = true;
+                addr += 65536;
+                memLen -= 65536;
+            }
+            return true;
+        }
+
+        private bool GenerateOutput() {
+            // Sum up the segment lengths to get the total project size.
+            int totalLen = 0;
+            foreach (SegmentMapEntry ent in mSegmentMap) {
+                if (ent == null) {
+                    continue;
+                }
+                totalLen += ent.Segment.Length;
+            }
+            Debug.WriteLine("Total length of loaded binary is " + totalLen);
+
+            byte[] data = new byte[totalLen];
+
+            // Create the project object.
+            DisasmProject proj = new DisasmProject();
+            proj.Initialize(data.Length);
+
+            // Try to get the Apple IIgs system definition.  This is fragile, because it
+            // relies on the name in the JSON file, but it's optional.  (If the default CPU
+            // type stops being 65816, we should be sure to set that here.)
+            try {
+                // TODO(maybe): encapsulate this somewhere else
+                string sysDefsPath = RuntimeDataAccess.GetPathName("SystemDefs.json");
+                SystemDefSet sds = SystemDefSet.ReadFile(sysDefsPath);
+                SystemDef sd = sds.FindEntryByName(IIGS_SYSTEM_DEF);
+                if (sd != null) {
+                    proj.ApplySystemDef(sd);
+                } else {
+                    Debug.WriteLine("Unable to find Apple IIgs system definition");
+                }
+            } catch (Exception) {
+                // never mind
+                Debug.WriteLine("Failed to apply Apple IIgs system definition");
+            }
+
+            // Add header comment.
+            string cmt = string.Format(Res.Strings.DEFAULT_HEADER_COMMENT_FMT, App.ProgramVersion);
+            proj.LongComments.Add(LineListGen.Line.HEADER_COMMENT_OFFSET,
+                new MultiLineComment(cmt));
+
+            ChangeSet cs = new ChangeSet(mSegmentMap.Count * 2);
+
+            // Load the segments, and add entries to the project.
+            int bufOffset = 0;
+            foreach (SegmentMapEntry ent in mSegmentMap) {
+                if (ent != null) {
+                    // Perform relocation.
+                    if (!RelocSegment(ent, data, bufOffset)) {
+                        return false;
+                    }
+
+                    // Add an address entry.
+
+                    // TODO: need to add multiple address entries if this straddles a segment.
+
+                    int origAddr = proj.AddrMap.Get(bufOffset);
+                    UndoableChange uc = UndoableChange.CreateAddressChange(bufOffset,
+                        origAddr, ent.Address);
+                    cs.Add(uc);
+
+                    // Add a comment identifying the segment.
+                    string segCmt = string.Format(Res.Strings.OMF_SEG_COMMENT_FMT,
+                        ent.Segment.SegNum, ent.Segment.SegName, ent.Segment.Kind);
+                    uc = UndoableChange.CreateLongCommentChange(bufOffset, null,
+                        new MultiLineComment(segCmt));
+                    cs.Add(uc);
+
+                    bufOffset += ent.Segment.Length;
+                }
+            }
+
+            proj.PrepForNew(data, "new_proj");
+            proj.ApplyChanges(cs, false, out RangeSet unused);
+
+            mLoadedData = data;
+            mNewProject = proj;
+            return true;
+        }
+
+        private bool RelocSegment(SegmentMapEntry ent, byte[] data, int bufOffset) {
+            //const int INVALID_RELOC = 0x00ffffff;
+            byte[] srcData = ent.Segment.GetConstData();
+            Array.Copy(srcData, 0, data, bufOffset, srcData.Length);
+
+            foreach (OmfReloc omfRel in ent.Segment.Relocs) {
+                int relocAddr = omfRel.RelOffset;
+                if (omfRel.FileNum != -1 && omfRel.FileNum != 1) {
+                    // Some other file; not much we can do with this.
+                    Debug.WriteLine("Unable to process reloc with FileNum=" + omfRel.FileNum);
+                    return false;
+                } else if (omfRel.SegNum == -1) {
+                    // Within this segment.
+                    relocAddr += ent.Address;
+                } else {
+                    // Find other segment.  This may fail if the file is damaged.
+                    if (omfRel.SegNum < 0 || omfRel.SegNum >= mSegmentMap.Count ||
+                            mSegmentMap[omfRel.SegNum] == null) {
+                        Debug.WriteLine("Reloc SegNum=" + omfRel.SegNum + " not in map");
+                        return false;
+                    } else {
+                        relocAddr += mSegmentMap[omfRel.SegNum].Address;
+                    }
+                }
+
+                if (omfRel.Shift < 0) {
+                    relocAddr >>= -omfRel.Shift;
+                } else if (omfRel.Shift > 0) {
+                    relocAddr <<= omfRel.Shift;
+                }
+
+                switch (omfRel.Width) {
+                    case 1:
+                        data[bufOffset + omfRel.Offset] = (byte)(relocAddr);
+                        break;
+                    case 2:
+                        data[bufOffset + omfRel.Offset] = (byte)(relocAddr);
+                        data[bufOffset + omfRel.Offset + 1] = (byte)(relocAddr >> 8);
+                        break;
+                    case 3:
+                        data[bufOffset + omfRel.Offset] = (byte)(relocAddr);
+                        data[bufOffset + omfRel.Offset + 1] = (byte)(relocAddr >> 8);
+                        data[bufOffset + omfRel.Offset + 2] = (byte)(relocAddr >> 16);
+                        break;
+                    default:
+                        Debug.WriteLine("Invalid reloc width " + omfRel.Width);
+                        return false;
+                }
+            }
+
+            return true;
+        }
+    }
+}
diff --git a/SourceGen/Tools/Omf/OmfFile.cs b/SourceGen/Tools/Omf/OmfFile.cs
index afe3b0e..7eb976b 100644
--- a/SourceGen/Tools/Omf/OmfFile.cs
+++ b/SourceGen/Tools/Omf/OmfFile.cs
@@ -95,6 +95,7 @@ namespace SourceGen.Tools.Omf {
             Debug.Assert(fileData.Length >= MIN_FILE_SIZE && fileData.Length <= MAX_FILE_SIZE);
             mFileData = fileData;
 
+            // Set to Unknown until analysis completes.
             OmfFileKind = FileKind.Unknown;
         }
 
diff --git a/SourceGen/Tools/Omf/OmfSegment.cs b/SourceGen/Tools/Omf/OmfSegment.cs
index b2576df..d87b1b2 100644
--- a/SourceGen/Tools/Omf/OmfSegment.cs
+++ b/SourceGen/Tools/Omf/OmfSegment.cs
@@ -174,6 +174,31 @@ namespace SourceGen.Tools.Omf {
         /// </summary>
         public List<OmfReloc> Relocs = new List<OmfReloc>();
 
+        /// <summary>
+        /// True if this is an ExpressLoad segment.
+        /// </summary>
+        public bool IsExpressLoad {
+            get {
+                if (Kind != SegmentKind.Data) {
+                    return false;
+                }
+                if ((Attrs & SegmentAttribute.Dynamic) == 0) {
+                    return false;
+                }
+                // Should be case-insensitive?  I'm assuming it's not padded with spaces since
+                // it's longer than 10 chars.
+                if (!(SegName == EXPRESSLOAD || SegName == EXPRESSLOAD_OLD)) {
+                    return false;
+                }
+                if (SegNum != 1) {
+                    Debug.WriteLine("WEIRD: ~ExpressLoad not first segment");
+                }
+                return true;
+            }
+        }
+        private const string EXPRESSLOAD = "~ExpressLoad";
+        private const string EXPRESSLOAD_OLD = "ExpressLoad";
+
 
         // Constructor is private; use ParseHeader() to create an instance.
         private OmfSegment() { }
diff --git a/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml b/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml
index c882068..773aef4 100644
--- a/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml
+++ b/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml
@@ -38,6 +38,9 @@ limitations under the License.
         <system:String x:Key="str_OmfFileSummaryFmt">This is {0}, with {1} segment</system:String>
         <system:String x:Key="str_OmfFileSummaryPlFmt">This is {0}, with {1} segments</system:String>
         <system:String x:Key="str_OmfFileNot">This is not an Apple II OMF file</system:String>
+
+        <system:String x:Key="str_OmfLoaderFail">Unable to prepare data file for project.</system:String>
+        <system:String x:Key="str_OmfConvertSuccessful">Data file and project created.</system:String>
     </Window.Resources>
 
     <Grid Margin="8">
@@ -93,7 +96,8 @@ limitations under the License.
         </TextBox>
 
         <DockPanel Grid.Row="5" LastChildFill="False" Margin="0,16,0,0">
-            <Button DockPanel.Dock="Left" Content="Convert to SourceGen Project" Padding="4,0"/>
+            <Button DockPanel.Dock="Left" Content="Generate SourceGen Project" Padding="4,0"
+                    IsEnabled="{Binding IsLoadFile}" Click="GenerateProject_Click"/>
             <Button DockPanel.Dock="Right" Content="Cancel" Width="70" IsCancel="True"/>
         </DockPanel>
     </Grid>
diff --git a/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml.cs b/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml.cs
index 6bc983f..cf79bdb 100644
--- a/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml.cs
+++ b/SourceGen/Tools/Omf/WpfGui/OmfViewer.xaml.cs
@@ -17,10 +17,12 @@ using System;
 using System.Collections.Generic;
 using System.ComponentModel;
 using System.Diagnostics;
+using System.IO;
 using System.Runtime.CompilerServices;
 using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Input;
+using Microsoft.Win32;
 
 using Asm65;
 
@@ -29,6 +31,8 @@ namespace SourceGen.Tools.Omf.WpfGui {
     /// Apple IIgs OMF file viewer.
     /// </summary>
     public partial class OmfViewer : Window, INotifyPropertyChanged {
+        private const string OUT_FILE_SUFFIX = "_ld";
+
         private OmfFile mOmfFile;
         private Formatter mFormatter;
 
@@ -93,6 +97,10 @@ namespace SourceGen.Tools.Omf.WpfGui {
             set { mMessageStrings = value; OnPropertyChanged(); }
         }
 
+        public bool IsLoadFile {
+            get { return mOmfFile.OmfFileKind == OmfFile.FileKind.Load; }
+        }
+
 
         /// <summary>
         /// Constructor.
@@ -156,5 +164,43 @@ namespace SourceGen.Tools.Omf.WpfGui {
             OmfSegmentViewer dlg = new OmfSegmentViewer(this, mOmfFile, item.OmfSeg, mFormatter);
             dlg.ShowDialog();
         }
+
+        private void GenerateProject_Click(object sender, RoutedEventArgs e) {
+            Loader loader = new Loader(mOmfFile);
+            if (!loader.Prepare()) {
+                // Unexpected.  If there's a valid reason for this, we need to add details
+                // to the error message.
+                string msg = (string)FindResource("str_OmfLoaderFail");
+                MessageBox.Show(msg, Res.Strings.OPERATION_FAILED);
+                return;
+            }
+
+            SaveFileDialog fileDlg = new SaveFileDialog() {
+                Filter = Res.Strings.FILE_FILTER_ALL,
+                FilterIndex = 1,
+                ValidateNames = true,
+                AddExtension = true,
+                FileName = Path.GetFileName(PathName) + OUT_FILE_SUFFIX
+            };
+            if (fileDlg.ShowDialog() != true) {
+                Debug.WriteLine("Save canceled by user");
+                return;
+            }
+            string pathName = Path.GetFullPath(fileDlg.FileName);
+
+            if (!loader.WriteProjectFiles(pathName, pathName + ProjectFile.FILENAME_EXT,
+                    out string errorMessage)) {
+                MessageBox.Show(Res.Strings.ERR_PROJECT_SAVE_FAIL + ": " + errorMessage,
+                    Res.Strings.OPERATION_FAILED,
+                    MessageBoxButton.OK, MessageBoxImage.Error);
+                return;
+            }
+
+            // Success!  Show a message then close the dialog.
+            string smsg = (string)FindResource("str_OmfConvertSuccessful");
+            MessageBox.Show(smsg, Res.Strings.OPERATION_SUCCEEDED,
+                MessageBoxButton.OK, MessageBoxImage.Information);
+            DialogResult = true;
+        }
     }
 }